[
  {
    "path": ".dockerignore",
    "content": "backend/dist\nbackend/localfiles\nDockerfile\nfrontend/.cache\nignore\nlatest.dump*\nnode_modules\nnpm-debug.log\n**/node_modules\n**/.env\n**/yarn-error.log\n**/.DS_Store"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\nindent_style = space\nindent_size = 4\ncharset = utf-8\ntrim_trailing_whitespace = false\ninsert_final_newline = true\n\n[package.json]\nindent_size = 2\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.md",
    "content": "---\nname: Bug Report\nabout: Create a bug report to help us out\ntitle: \"\"\nlabels: bug\nassignees: \"\"\n---\n\n# Feature request story or issue\n\n## Screenshot\n\nYOUR SCREENSHOT HERE:\n\n## Url\n\n-   Url where you took the screenshot:\n-   URL:\n\n## A sentence description of the problem\n\nShort DESCRIPTION:\n\nThat's it! Thanks.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"[Feature Request]\"\nlabels: enhancement\nassignees: \"\"\n---\n\n**Is your feature request related to a problem? There is a Please describe.**\nA clear and concise description of what the problem is. Ex. When I want to do X, I am always missing Y to make it easier [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Take a screenshot of the area you're working in**\nEven if the feature request doesn't exist there yet, what area would the feature request help within?\nSCREENSHOT:\n\nURL (of where you're working):\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: build\non: [push]\njobs:\n    test:\n        runs-on: ubuntu-latest\n        services:\n            postgres:\n                image: postgres:12\n                env:\n                    POSTGRES_USER: r-board\n                    POSTGRES_PASSWORD: secret\n                ports:\n                    - 13338:5432\n                # Set health checks to wait until postgres has started\n                options: >-\n                    --health-cmd pg_isready\n                    --health-interval 10s\n                    --health-timeout 5s\n                    --health-retries 5\n        strategy:\n            matrix:\n                node-version: [18.x]\n        steps:\n            - uses: actions/checkout@v2\n            - name: Use Node.js ${{ matrix.node-version }}\n              uses: actions/setup-node@v1\n              with:\n                  node-version: ${{ matrix.node-version }}\n            - name: Install deps\n              run: yarn\n            - name: Run unit tests\n              run: yarn test:unit\n            - name: Wait for DB\n              run: yarn wait-for-db\n            - name: Run integration tests\n              run: yarn test:integration\n            - name: Build\n              run: yarn build\n            - name: Start server\n              run: yarn start&\n              env:\n                  SESSION_SIGNING_SECRET: notsosecretthing\n            - name: Prepare Playwright\n              run: npx playwright install chromium firefox\n            - name: Run playwright tests\n              run: yarn test:playwright\n            - name: Archive results\n              if: always()\n              uses: actions/upload-artifact@v4\n              with:\n                  name: test-results\n                  path: |\n                      playwright/results\n    lint:\n        runs-on: ubuntu-latest\n        strategy:\n            matrix:\n                node-version: [18.x]\n        steps:\n            - uses: actions/checkout@v2\n            - name: Use Node.js ${{ matrix.node-version }}\n              uses: actions/setup-node@v1\n              with:\n                  node-version: ${{ matrix.node-version }}\n            - name: Install dependencies\n              run: yarn install --frozen-lockfile\n            - name: Check code formatting & generated files\n              run: yarn lint\n    docker-image:\n        needs: test\n        runs-on: ubuntu-latest\n        if: github.ref == 'refs/heads/master'\n        steps:\n            - uses: actions/checkout@v2\n            - name: docker login\n              env:\n                  DOCKER_HUB_USER: ${{secrets.DOCKER_HUB_USER}}\n                  DOCKER_HUB_PASSWORD: ${{secrets.DOCKER_HUB_PASSWORD}}\n              run: docker login -u $DOCKER_HUB_USER -p $DOCKER_HUB_PASSWORD\n            - name: docker build\n              run: docker build . -t raimohanska/ourboard:latest -t raimohanska/ourboard:${{github.sha}}\n            - name: docker push\n              run: docker push raimohanska/ourboard:latest && docker push raimohanska/ourboard:${{github.sha}}\n"
  },
  {
    "path": ".gitignore",
    "content": ".cache/\nnode_modules/\ndist/\nyarn-error.log\n.env\n*.mp4\nbackend/localfiles/\n/ignore\n.DS_Store\nlatest.dump\n.vscode\nbackups\nplaywright/results\n"
  },
  {
    "path": ".huskyrc.js",
    "content": "module.exports = {\n    hooks: {\n        \"pre-commit\": \"lint-staged\",\n    },\n}\n"
  },
  {
    "path": ".nvmrc",
    "content": "18\n"
  },
  {
    "path": ".prettierignore",
    "content": "package.json\n.cache/\nnode_modules/\ndist/\nyarn-error.log\n.env\n*.mp4\nbackend/localfiles/\nignore/\n"
  },
  {
    "path": ".prettierrc.js",
    "content": "module.exports = {\n    printWidth: 120,\n    semi: false,\n    trailingComma: \"all\",\n}\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:18 as builder\n\n# Create app directory\nWORKDIR /usr/src/app\n\nCOPY package.json yarn.lock ./\nCOPY frontend/package.json ./frontend/\nCOPY backend/package.json ./backend/\nCOPY perf-tester/package.json ./perf-tester/\n\nRUN yarn install --frozen-lockfile --non-interactive\n\nCOPY backend ./backend\nCOPY frontend ./frontend\nCOPY common ./common\nCOPY perf-tester ./perf-tester\nCOPY tsconfig.json .\n\nrun yarn build\n\nFROM node:18 as runner\n\nCOPY --from=builder /usr/src/app/backend/dist/index.js /usr/src/app/backend/dist/index.js\nCOPY --from=builder /usr/src/app/backend/migrations /usr/src/app/backend/migrations\nCOPY --from=builder /usr/src/app/frontend/public /usr/src/app/frontend/public\nCOPY --from=builder /usr/src/app/frontend/dist /usr/src/app/frontend/dist\nWORKDIR /usr/src/app\nEXPOSE 1337\n\nWORKDIR /usr/src/app/backend\nCMD [ \"node\", \"dist/index.js\" ]"
  },
  {
    "path": "LICENSE.txt",
    "content": "This project is licensed under the MIT license.\nCopyrights are respective of each contributor listed at the beginning of each definition file.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "README.md",
    "content": "An online whiteboard. Think of it as very poor man's Miro that's open source, free to use and which you can also host yourself. Feel free to try at https://www.ourboard.io/\n\nIn this Readme:\n\n-   [User guide](#features-and-user-guide)\n-   [API](#api)\n-   [Development instructions](#development)\n-   [Hosting Ourboard](#hosting)\n\n## Features and User Guide\n\nThe user guide here is bound to be incomplete and out-of-date. Feel welcome to improve it!\n\n### Basics\n\nSetting your nickname or sign in\n\n-   Click on the top right corner nickname field to change your nickname\n-   Optionally, log in with your Google user account. This allows you to create private boards and to track your favorite boards across devices\n\nAdding items\n\n-   Drag from palette\n-   Double click to add a new note\n-   Use keyboard shortcuts below\n\nAdding links\n\n-   Paste a link to text, it'll automatically converted to a hyperlink\n-   Select text and paste a link to convert the text to a link\n\nAdding images\n\n-   Add by dragging a file from your computer or from a browser window\n-   Add by pasting an image from the clipboard using Command-V\n\nOrganizing your board\n\n-   Create an Area and drag items on it to keep them together. When you move the Area, the items move with it!\n-   Use Areas, lines, connections and images to give a visual structure to your board\n-   Lock items in place to prevent accidental moves of static items and lines\n-   Use \"structured stickies\": 1. Create an Area to be used as a template, choose a nice color 2. Add template content, e.g.. labels like \"size\" 3. Lock the labels in place. Now you can clone the whole area with a single click on the Clone button (or Cmd-D).\n\nCopy and paste\n\n-   You can cut/copy/paste contents on the board using keyboard shortcuts\n-   Copy-paste works across boards, so you can do a \"backup\" by selecting all notes and pasting on another board\n-   You should be able to paste text and images on the board from other applications as well\n-   You can create a full clone of your current board by clicking on the Clone button beside board name\n\nKeyboard shortcuts\n\nThese are for Mac. For other Linux/Windows, replace Command with Control.\n\n```\nDEL/Backspace       Delete item\nA                   Create new area\nN                   Create new note\nT                   Create new text box\nC                   Select the Connect tool\nEsc                 Select the default tool\nH                   Hide contents of an area\nCommand-A           Select all items\nCommand-V           Paste\nCommand-C           Copy\nCommand-X           Cut\nCommand-Z           Undo\nCommand-Shift-Z     Redo\nCommand-D           Duplicate\nCommand-Minus       Zoom out\nCommand-Plus        Zoom in\nCommand-Zero        Reset zoom\nArrow keys          Move selected items. SHIFT for big steps, ALT for fine-tuning.\n```\n\nPro tips\n\n-   You can drag the toolbar / palette around, if it gets in your way at the top-center position\n\n### Board access controls\n\nAll boards created accessible to anyone with the link by default. If you Sign In using Google authentication, you'll also be able to create boards with restricted access. It's possible to grant access to certain emails or to people with an email in a given domain.\n\n## API\n\nOurboard has a limited HTTP API for creating, exporting and updating boards.\n\nAll POST and PUT endpoints accept application/json content.\n\nAPI requests against boards with restricted access require you to supply an API_TOKEN header with a valid API token.\nThe token is returned in the response of the POST request used to create the board.\n\n### POST /api/v1/board\n\nCreates a new board. Payload:\n\n```js\n{\n    \"name\": \"board name as string\",\n}\n```\n\nYou can also specify board access policy, including individual users by email and user email domains:\n\n```js\n{\n    \"name\": \"board name as string\",\n    \"accessPolicy\": {\n        \"allowList\": [\n            { email: \"coolgirl@reaktor.com\" },\n            { domain: \"reaktor.fi\" }\n        ]\n    }\n}\n```\n\nResponse:\n\n```js\n{\n    \"id\": \"board id\",\n    \"accessToken\": \"************\"\n}\n```\n\nThe `accessToken` returned here is required for further API calls in case you set an access policy. So, make sure to save the token.\n\n### PUT /api/v1/board/:boardId\n\nChanges board name and, optionally, access policy. Payload is similar to the POST request above.\n\nThis endpoint always requires the API_TOKEN header.\n\n### POST /api/v1/board/:boardId/item\n\nCreates a new item on given board. If you want to add the item onto a specific area/container element on the board, you can\nfind the id of the container by inspecting with your browser.\n\nTo create a new item inside a container element:\n\n```js\n{\n    \"type\": \"note\",\n    \"text\": \"text on note\",\n    \"container\": \"container element text or id\",\n    \"color\": \"hexadecimal color code\"\n}\n```\n\nTo create a new item using coordinates:\n\n```js\n{\n    \"type\": \"note\",\n    \"text\": \"text on note\",\n    \"color\": \"hexadecimal color code\",\n    \"x\": 100,\n    \"y\": 100,\n    \"width\": 100,\n    \"height\": 100\n}\n```\n\nResponse:\n\n```js\n{\n    \"id\": \"ITEM_ID\"\n}\n```\n\n### PUT /api/v1/board/:boardId/item/:itemId\n\nCreates a new item on given board or updates an existing one.\nIf you want to add the item onto a specific area/container element on the board, you can\nfind the id of the container by inspecting with your browser.\n\nPayload:\n\n```js\n{\n    \"type\": \"note\",\n    \"text\": \"text on note\",\n    \"container\": \"container element text or id\",\n    \"color\": \"hexadecimal color code\",\n    \"replaceTextIfExists\": boolean,      // Override text if item with this id exists. Defaults to false.\n    \"replaceColorIfExists\": boolean,     // Override color if item with this id exists. Defaults to false.\n    \"replaceContainerIfExists\": boolean, // Override container in item with this id exists. Defaults to true.\n}\n```\n\nor\n\n```js\n{\n    \"x\": \"integer\",\n    \"y\": \"integer\",\n    \"type\": \"note\",\n    \"text\": \"text on note\",\n    \"color\": \"hexadecimal color code\",\n    \"width\": \"integer\",\n    \"height\": \"integer\",\n}\n```\n\n### GET /api/v1/board/:boardId\n\nReturn board current state as JSON.\n\n### GET /api/v1/board/:boardId/hierarchy\n\nReturn board current state in a hierarchical format (items inside containers)\n\n### GET /api/v1/board/:boardId/csv\n\nReturn board current state in CSV format, where\n\n-   A container containing only leaf items (note, text) creates a row and each item in that container gets its own column\n-   Container name is a column on the left\n-   Any wrapping containers also add a column on the left\n\n### GET /api/v1/board/:boardId/history\n\nReturns the full history of given board as JSON.\n\n## Github Issues Integration\n\n1. Create a board and an Area named \"new issues\" (case insensitive) on the board.\n2. Add a webhook to a git repo, namely\n    1. Use URL https://www.ourboard.io/api/v1/webhook/github/{board-id}, with board-id from the URL of you board.\n    2. Use content type to application/json\n    3. Select \"Let me select individual events\" and pick Issues only.\n3. Create a new issue or change labels of an existing issue.\n4. You should see new notes appear on your board\n\n## Development\n\nRunning locally requires `docker-compose` which is used for starting the local PostgreSQL database. The script below starts the database, but you must make sure you have a working docker setup on your machine, of course.\n\nRunning locally:\n\n```\nyarn install\nyarn dev\n```\n\nRun end-to end Playwright tests\n\n-   `yarn test:playwright` to run tests once\n-   `yarn test:playwright --ui` to open the Playwright UI\n\nConnect to the local PostgreSQL database\n\n    yarn psql\n\n### Tech stack\n\n-   TypeScript\n-   [Harmaja](https://github.com/raimohanska/harmaja) frontend library\n-   WebSockets (express-ws / uWebSockets.js both!)\n-   Express server\n-   Typera for HTTP API\n\n### Developing with production data\n\nDo not run your local server against the production database, or you'll corrupt production. The server's in memory state will be out of sync with DB and bad things will happen.\n\nInstead, do this.\n\n1. Capture a backup and download it: `heroku pg:backups:capture`, then `heroku pg:backups:download`.\n2. Restore the backup to your local database: `pg_restore --verbose --clean --no-acl --no-owner -d postgres://r-board:secret@localhost:13338/r-board latest.dump`\n3. Start you local server using `yarn dev`\n\nIf you need the local state for a given board in localStorage, you can\n\n1. extract the content in the browser devtools, when viewing production site in browser, using `localStorage[\"board_<boardid>\"]`\n2. Copy that string to clipboard\n3. Run the following in your localhost site console:\n\n    localStorage[\"board_32de1a50-09a6-4453-9b9e-ed10c56afa99\"]=JSON.stringify(\n    <paste content here>\n    )\n\nCopy the result string, navigate to your localhost site and paste the same value to the same localStorage key. Refresh and enjoy.\n\n### Building and pushing the raimohanska/ourboard docker image\n\n```\ndocker login\ndocker build . -t raimohanska/ourboard:latest\ndocker push raimohanska/ourboard:latest\n```\n\n## Hosting\n\nOurBoard is made to be easy to deploy and host. It consists of a single Node.js process that needs a PostgreSQL database for storing data. Using environment variables (see below) you can set the URL for the database and optionally configure OurBoard to use S3 for image assets and Google for authentication. By default it comes without authentication / authorization and stores image assets in the local file system.\n\n### Heroku\n\nIf it suits you, Heroku is likely to be the easiest way to host your own OurBoard server. You should be able to host your own OurBoard instance pretty easily in Heroku. This repository should be runnable as-is,\nprovided you set up some environment variables, which are listed below.\n\n### Docker\n\nOurBoard is available as a Docker image so you can deploy it with Docker or your favorite container environment of choice. To get an OurBoard docker image, you can either:\n\n1. Use the [raimohanska/ourboard image](https://hub.docker.com/r/raimohanska/ourboard) in Docker Hub (just skip to running, it will be downloaded automatically)\n2. Build it from this repository: `docker build . -t raimohanska/ourboard:latest`\n\nYou can run it like this:\n\n1. Start a posgres database. For example, running `docker-compose up` in your local clone of this directory will start one.\n2. Start the Ourboard container. Run the example script `scripts/run_dockerized.sh` to try it out.\n\nWith the example script, you'll have a setup which\n\n-   Doesn't have authentication. See environment variables below for configuring Google authentication, which is the only supported option for now.\n-   Stores uploaded assets (images) on the local filesystem. The example script binds the local directory `backend/localfiles` to be used for storage. In your own script, you'll probably want to point out a more suitable directory on your server machine.\n-   Uses an absolutely insecure SESSION_SIGNING_KEY. Make sure to use a long random string instead.\n-   Uses plain HTTP and responds at http://localhost:1337. If you want it to respond at some other URL, you'll need to set the ROOT\\__\\_URL variable (and all the WS_ variables unless you have the latest ourboard image)\n\nRead on!\n\n### Environment variables\n\nThe OurBoard server is configured using environment variables. Here's a most likely incomplete list of supported environment variables for the server.\n\nWhen developing the application locally, set these variables in `backend/.env` file. The most important first - you'll most likely need to set these.\n\n```\nDATABASE_URL          Postgres database URL. In Heroku, you can just add the PostgreSQL add on and this variable will be correctly set. The free one will get you started.\nROOT_URL              Root URL used for redirects. Use https://<yourdomain>/. If you don't have authentication configured or you're actually planning to access your server using the address http://localhost:1337, you can omit this one.\nPORT                  HTTP port that OurBoard should bind. Defaults to 1337.\n```\n\nHTTPS and TLS related settings:\n\n```\nHTTPS_PORT            Local port to use for HTTPS sockets. Use this if you want OurBoard to terminate HTTPS. If you use a proxy like NGINX or run in a hosted environment like Heroku, you won't be needing this one.\nHTTPS_CERT_FILE       Path to HTTPS certificate file. When running in docker, make sure to add appropriate mounts to make the file available to the dockerized process.\nHTTPS_KEY_FILE        Path to HTTPS key file. When running in docker, make sure to add appropriate mounts to make the file available to the dockerized process.\nREDIRECT_URL          Put your OurBoard application root URL here, if you want the server to redirect all requests that don't have the x-forwarded-proto=https\nDATABASE_SSL_ENABLED  Use `true` to use SSL for database connection. Recommended.\n```\n\nAWS environment variables, needed for hosting board assets on AWS S3. If these are missing, all uploaded\nassests (images, videos) will be stored in the local filesystem (using the path \"localfiles\"),\nwhich is a viable solution only if you have a persistent file system with backups.\n\n```\nAWS_ACCESS_KEY_ID       AWS access key ID\nAWS_SECRET_ACCESS_KEY   Secret access key\nAWS_ASSETS_BUCKET_URL   URL to the AWS bucket. For example https://r-board-assets.s3.eu-north-1.amazonaws.com\n```\n\nThe experimental collaborative editing feature is controlled using environment variables as well:\n\n```\nCOLLABORATIVE_EDITING   `true` to enable for all new boards, `false` to disable for new boards, `opt-in` to allow opt-in on creation (default), `opt-in-authenticated` to allow opt-in for authenticated users only\n```\n\nAnd finally some more settings you're unlikely to need.\n\n```\nWS_HOST_DEFAULT       Your domain name here, used for routing websocket connections. Is automatically derived from ROOT_URL in latest image.\nWS_HOST_LOCAL         Your domain name here as well. Is automatically derived from ROOT_URL in latest image.\nWS_PROTOCOL           `wss` for secure, `ws` for non-secure WebSockets. Is automatically derived from ROOT_URL in latest image.\nBOARD_ALIAS_tutorial  Board identifier for the \"tutorial\" board that will be cloned for new users. Allows you to create a custom tutorial board. For example, the value `782d4942-a438-44c9-ad5f-3187cc1d0a63` is used in ourboard.io, and this points to a publicly readable, but privately editable board\n```\n\n### Authentication configuration\n\nOurboard can be configured to use Google or generic OpenID Connect (OIDC) authentication using environment variables.\n\nEven if you set an auth provider (see below), the server will default to allowing anonymous access as well - only boards that are explicitly set with access restrictions will require authentication. However, if you want to require authentication for all access, you can use the following environment variable.\n\n```\nREQUIRE_AUTH        Use `true` to require authentication for all access.\n```\n\n#### Google authentication\n\nGoogle authentication is supported. To enable this feature, you'll need to supply the following environment variables.\n\n```\nGOOGLE_OAUTH_CLIENT_ID\nGOOGLE_OAUTH_CLIENT_SECRET\n```\n\nYou'll of course need to set up an account on the Google side and configure a client so that you can get the client id and secret variables you'll use on OurBoard side. When configuring the Google client, you should allow the URL `<OURBOARD_ROOT_URL>/google-callback` as a valid callback URL.\n\n#### OpenID Connect configuration\n\nGeneric OpenID Connect (OIDC) authentication is also supported as an experimental feature. To enable this feature, you'll need to supply the following environment variables.\n\n```\nOIDC_CONFIG_URL        Your OpenID configuration endpoint. For example: https://accounts.google.com/.well-known/openid-configuration\nOIDC_CLIENT_ID         Your OAuth2 client id\nOIDC_CLIENT_SECRET     Your OAuth2 client secret\nOIDC_LOGOUT            URL to redirect the user after a logout on Ourboard. This allows you to sign out from the OIDC provider. You can also use the value `true` to automatically determine this URL based on the `end_session_endpoint` field in the response from the OIDC_CONFIG_URL endpoint. If omitted and `REQUIRE_AUTH=true` is not set, OurBoard will simply allow anonymous usage after a logout.\n```\n\nYou'll of course need an external auth provider and configure a client so that you can get the client id and secret variables you'll use on OurBoard side. When configuring the OIDC client, you should allow the URL `<OURBOARD_ROOT_URL>/google-callback` as a valid callback URL. OurBoard uses the OAuth \"standard flow\" or \"authorization code flow\" and expects to be able to find your OIDC configuration at the URL pointed by tge `OIDC_CONFIG_URL` environment variable.\n\nIn the Id Token received from the Auth provider, OurBoard expects to find the following claims:\n\n-   Either `name` or `preferred_username` representing the display name for the user\n-   `email` representing the email address of the user. OurBoard does not expect this to be a valid email address; it just uses the email as the unique identifier for the user.\n-   Optional `picture` for a URL for the user's profile picture\n\nThus far, I've tested Ourboard OIDC with Google and Keycloak.\n\n#### OpenID Connect Using KeyCloak\n\nNOTICE: This is just a simple example for testing and **not a production-grade setup**. Make sure to configure Keycloak properly before using it in production.\n\nAn example KeyCloak setup is bundled with the OurBoard development environment. To try it, set the following environment variables in `backend/.env`:\n\n```\nOICD_CONFIG_URL=http://127.0.0.1:8080/realms/ourboard/.well-known/openid-configuration\nOIDC_CLIENT_ID=ourboard\nOIDC_CLIENT_SECRET=S2qHjCg12IDxz89Lffo49NQ19ooWCUwF\n```\n\nWhen you start OurBoard in development mode using `yarn dev-with-keycloak`, you can now Sign In using the username `ourboard-test` and password `password`.\n\n## Contribution\n\nSee Issues!\n\n## A word from our sponsor\n\nI want to thank [Reaktor](https://www.reaktor.com/) for the huge and essential support for this project!\n\n-   We (Reaktorian contributors) get some monetary support from the Reaktor open-source support program\n-   Hosting costs covered by Reaktor (Heroku, AWS)\n-   The UI design was done by Reaktor's experts (Mira Myllylä for the tool and Mari Halla-aho for the dashboard)\n"
  },
  {
    "path": "backend/migrations/001_init.js",
    "content": "exports.up = (pgm) => {\n    pgm.sql(`\n    CREATE TABLE IF NOT EXISTS board (id text PRIMARY KEY, name text NOT NULL);\n    ALTER TABLE board ADD COLUMN IF NOT EXISTS content JSONB NOT NULL;\n    ALTER TABLE board ADD COLUMN IF NOT EXISTS history JSONB NOT NULL default '[]';            \n    CREATE TABLE IF NOT EXISTS board_event (board_id text REFERENCES board(id), last_serial integer, events JSONB NOT NULL, PRIMARY KEY (board_id, last_serial));\n    ALTER TABLE board_event ALTER COLUMN last_serial SET NOT NULL;\n    ALTER TABLE board_event ALTER COLUMN board_id SET NOT NULL;\n    CREATE TABLE IF NOT EXISTS board_api_token (board_id text REFERENCES board(id), token TEXT NOT NULL);\n    CREATE TABLE IF NOT EXISTS app_user (id text PRIMARY KEY, email text NOT NULL);\n    CREATE TABLE IF NOT EXISTS user_board (user_id text REFERENCES app_user(id), board_id text REFERENCES board(id), last_opened TIMESTAMP NOT NULL, PRIMARY KEY (user_id, board_id));\n    ALTER TABLE board ADD COLUMN IF NOT EXISTS ws_host TEXT NULL;\n    ALTER TABLE board ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NULL DEFAULT now();            \n    ALTER TABLE board_event ADD COLUMN IF NOT EXISTS saved_at TIMESTAMP NULL DEFAULT now();\n    `)\n}\n"
  },
  {
    "path": "backend/migrations/002_add_first_serial.js",
    "content": "exports.up = (pgm) => {\n    pgm.sql(`\n    ALTER TABLE board_event ADD COLUMN IF NOT EXISTS first_serial int;\n    UPDATE board_event SET first_serial = COALESCE(CAST(events#>'{events, 0, serial}' as int), 0);\n    ALTER TABLE board_event ALTER COLUMN first_serial SET NOT NULL;\n    `)\n}\n"
  },
  {
    "path": "backend/migrations/003_refactor_access.sql",
    "content": "alter table board add public_read boolean null;\nalter table board add public_write boolean null;\ncreate table board_access (\n  board_id text not null references board(id),\n  domain text null,\n  email text null,\n  access text null\n);\n\nwith allow_json as (\nselect id, jsonb_array_elements(content -> 'accessPolicy' -> 'allowList') as e\nfrom board),\n\nallow_entry as (\n\tselect id, e ->> 'domain' as domain, e ->> 'email' as email, e ->> 'access' as access\n\tfrom allow_json\n)\n\ninsert into board_access(board_id, domain, email, access) (\n  select ae.id as board_id, ae.domain, ae.email, ae.access\n  from allow_entry ae\n);\n\nupdate board\nset public_read = coalesce(content -> 'accessPolicy' ->> 'publicRead', 'false') :: boolean,\n    public_write = coalesce(content -> 'accessPolicy' ->> 'publicWrite', 'false') :: boolean\nwhere content -> 'accessPolicy' is not null"
  },
  {
    "path": "backend/migrations/004_public_boards_access.sql",
    "content": "update board\nset public_read = 't',\n    public_write = 't'\nwhere content -> 'accessPolicy' is null"
  },
  {
    "path": "backend/migrations/005_uuid_extension.sql",
    "content": "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";"
  },
  {
    "path": "backend/migrations/006_unique_email.sql",
    "content": "ALTER TABLE app_user ADD CONSTRAINT unique_email UNIQUE (email);"
  },
  {
    "path": "backend/migrations/007_add_crdt_update.sql",
    "content": "alter table board_event add column crdt_update bytea null;"
  },
  {
    "path": "backend/migrations/008_allow_crdt_only_bundle.sql",
    "content": "alter table board_event alter column first_serial drop not null;\nalter table board_event add constraint first_serial_or_crdt check (crdt_update is not null OR first_serial is not null);"
  },
  {
    "path": "backend/migrations/009_drop_board_event_primary_key.sql",
    "content": "ALTER TABLE board_event DROP CONSTRAINT board_event_pkey;\nCREATE INDEX board_event_board_index ON board_event (board_id);"
  },
  {
    "path": "backend/migrations/010_drop_history_column.sql",
    "content": "alter table board drop column history;"
  },
  {
    "path": "backend/package.json",
    "content": "{\n  \"name\": \"rboard-backend\",\n  \"version\": \"1.0.0\",\n  \"main\": \"dist/index.js\",\n  \"license\": \"MIT\",\n  \"dependencies\": {\n    \"@types/cookies\": \"^0.7.7\",\n    \"@types/date-fns\": \"^2.6.0\",\n    \"@types/express-ws\": \"^3.0.0\",\n    \"@types/html-entities\": \"^1.2.16\",\n    \"@types/json-diff\": \"^0.5.0\",\n    \"@types/lodash\": \"^4.14.161\",\n    \"@types/node\": \"^14.6.2\",\n    \"@types/pg\": \"^7.14.4\",\n    \"@types/ramda\": \"^0.27.29\",\n    \"@types/tcp-port-used\": \"^1.0.0\",\n    \"@types/ws\": \"^7.4.0\",\n    \"aws-sdk\": \"^2.778.0\",\n    \"cookies\": \"^0.8.0\",\n    \"csv-writer\": \"^1.6.0\",\n    \"date-fns\": \"^2.17.0\",\n    \"dotenv\": \"^8.2.0\",\n    \"express\": \"^4.17.1\",\n    \"express-ws\": \"^4.0.0\",\n    \"fp-ts\": \"^2.9.5\",\n    \"google-auth-library\": \"^7.0.2\",\n    \"googleapis\": \"^110.0.0\",\n    \"html-entities\": \"^2.1.0\",\n    \"io-ts\": \"^2.2.15\",\n    \"io-ts-types\": \"^0.5.15\",\n    \"json-diff\": \"^0.5.4\",\n    \"jsonwebtoken\": \"^8.5.1\",\n    \"lodash\": \"^4.17.20\",\n    \"lonna\": \"^0.12.2\",\n    \"monocle-ts\": \"^2.3.7\",\n    \"newtype-ts\": \"^0.3.4\",\n    \"node-pg-migrate\": \"^6.0.0\",\n\n    \"pg\": \"^8.3.3\",\n    \"pg-query-stream\": \"^4.2.1\",\n\n    \"tcp-port-used\": \"^1.0.2\",\n    \"tsx\": \"3.13.0\",\n    \"typera-express\": \"^2.3.0\",\n\n    \"typescript\": \"^5.3\",\n    \"uWebSockets.js\": \"uNetworking/uWebSockets.js#v18.14.0\",\n    \"uuid\": \"^8.3.0\",\n    \"uuid4\": \"^2.0.2\",\n    \"y-protocols\": \"^1.0.6\"\n  },\n  \"scripts\": {\n    \"start\": \"node --enable-source-maps .\",\n    \"dev\": \"npm-run-all --parallel watch\",\n    \"build\": \"tsc && ncc build dist/backend/src/server.js -o dist\",\n    \"watch\": \"tsc-watch --onSuccess \\\"node --enable-source-maps dist/backend/src/server.js\\\" --preserveWatchOutput\",\n    \"watch-ts\": \"tsc-watch --preserveWatchOutput\",\n\n\n    \"lint\": \"echo 'No linting configured'\"\n  },\n  \"engines\": {\n    \"node\": \">=14\"\n  },\n  \"devDependencies\": {\n    \"@types/express\": \"^4.17.7\",\n    \"@types/jsonwebtoken\": \"^8.5.0\",\n    \"@types/node-fetch\": \"^2.5.9\",\n\n    \"@types/uuid\": \"^8.3.0\",\n    \"@vercel/ncc\": \"^0.38.1\",\n    \"tsc-watch\": \"^4.2.9\"\n  }\n}\n"
  },
  {
    "path": "backend/src/api/api-routes.ts",
    "content": "import { router } from \"typera-express\"\nimport { boardCreate } from \"./board-create\"\nimport { boardCSVGet } from \"./board-csv-get\"\nimport { boardGet } from \"./board-get\"\nimport { boardHierarchyGet } from \"./board-hierarchy-get\"\nimport { boardHistoryGet } from \"./board-history-get\"\nimport { boardUpdate } from \"./board-update\"\nimport { githubWebhook } from \"./github-webhook\"\nimport { itemCreate } from \"./item-create\"\nimport { itemCreateOrUpdate } from \"./item-create-or-update\"\n\nexport default router(\n    boardGet,\n    boardHierarchyGet,\n    boardCSVGet,\n    boardCreate,\n    boardUpdate,\n    githubWebhook,\n    itemCreate,\n    itemCreateOrUpdate,\n    boardHistoryGet,\n)\n"
  },
  {
    "path": "backend/src/api/board-create.ts",
    "content": "import * as t from \"io-ts\"\nimport { NonEmptyString } from \"io-ts-types\"\nimport { ok } from \"typera-common/response\"\nimport { body } from \"typera-express/parser\"\nimport {\n    Board,\n    BoardAccessPolicyCodec,\n    CrdtDisabled,\n    CrdtEnabled,\n    newBoard,\n    optional,\n} from \"../../../common/src/domain\"\nimport { addBoard } from \"../board-state\"\nimport { route } from \"./utils\"\nimport { getConfig } from \"../config\"\n/**\n * Creates a new board.\n *\n * @tags Board\n */\nexport const boardCreate = route\n    .post(\"/api/v1/board\")\n    .use(body(t.type({ name: NonEmptyString, accessPolicy: BoardAccessPolicyCodec, crdt: optional(t.literal(true)) })))\n    .handler(async (request) => {\n        const crdt = (getConfig().crdt === \"true\" || request.body.crdt) ?? false\n        let board: Board = newBoard(request.body.name, crdt ? CrdtEnabled : CrdtDisabled, request.body.accessPolicy)\n        const boardWithHistory = await addBoard(board, true)\n        return ok({ id: boardWithHistory.board.id, accessToken: boardWithHistory.accessTokens[0] })\n    })\n"
  },
  {
    "path": "backend/src/api/board-csv-get.ts",
    "content": "import { createArrayCsvStringifier } from \"csv-writer\"\nimport _ from \"lodash\"\nimport { ok } from \"typera-common/response\"\nimport { Board, Container, Item, TextItem } from \"../../../common/src/domain\"\nimport { apiTokenHeader, checkBoardAPIAccess, route } from \"./utils\"\nimport { augmentBoardWithCRDT } from \"../../../common/src/board-crdt-helper\"\nimport { yWebSocketServer } from \"../board-yjs-server\"\n\n/**\n * Gets board current contents\n *\n * @tags Board\n */\nexport const boardCSVGet = route\n    .get(\"/api/v1/board/:boardId/csv\")\n    .use(apiTokenHeader)\n    .handler((request) =>\n        checkBoardAPIAccess(request, async (boardState) => {\n            const board = augmentBoardWithCRDT(\n                await yWebSocketServer.docs.getYDocAndWaitForFetch(boardState.board.id),\n                boardState.board,\n            )\n            const textItemsWithParent = Object.values(board.items).filter(\n                (i) => i.containerId !== undefined && (i.type === \"text\" || i.type === \"note\"),\n            )\n            const textItemGroups = _.groupBy(textItemsWithParent, (i) => i.containerId)\n            const rows = Object.entries(textItemGroups).map(([parentId, textItems]) => {\n                const rowContainer = board.items[parentId]\n                return {\n                    parents: parentChain(board)(rowContainer),\n                    rowContainer,\n                    textItems,\n                } as Row\n            })\n            if (rows.length === 0) return csv(board, [])\n            const maxDepth = _.max(rows.map((r) => r.parents.length))!\n            const csvData = rows.map((r) => {\n                return [\n                    ...r.parents.map((c) => c.text),\n                    ..._.times(maxDepth - r.parents.length, () => \"\"),\n                    r.rowContainer.text,\n                    ...r.textItems.map((i) => i.text),\n                ]\n            })\n            return csv(board, csvData)\n        }),\n    )\n\ntype Row = { parents: Container[]; rowContainer: Container; textItems: TextItem[] }\n\nconst parentChain = (board: Board) => (item: Item): Container[] => {\n    if (!item.containerId) return []\n    const parent = board.items[item.containerId]\n    if (parent.type !== \"container\")\n        throw Error(`Parent item ${item.containerId} is of type ${parent.type}, expecting container`)\n    return [parent, ...parentChain(board)(parent)]\n}\n\nfunction csv(board: Board, rows: string[][]) {\n    const result = createArrayCsvStringifier({}).stringifyRecords(rows)\n    return ok(result, { \"content-type\": \"text/csv\", \"content-disposition\": `attachment; filename=${board.name}.csv` })\n}\n"
  },
  {
    "path": "backend/src/api/board-get.ts",
    "content": "import { ok } from \"typera-common/response\"\nimport { apiTokenHeader, checkBoardAPIAccess, route } from \"./utils\"\nimport { yWebSocketServer } from \"../board-yjs-server\"\nimport { augmentBoardWithCRDT } from \"../../../common/src/board-crdt-helper\"\n\n/**\n * Gets board current contents\n *\n * @tags Board\n */\nexport const boardGet = route\n    .get(\"/api/v1/board/:boardId\")\n    .use(apiTokenHeader)\n    .handler((request) =>\n        checkBoardAPIAccess(request, async (boardState) => {\n            const board = augmentBoardWithCRDT(\n                await yWebSocketServer.docs.getYDocAndWaitForFetch(boardState.board.id),\n                boardState.board,\n            )\n            return ok({ board })\n        }),\n    )\n"
  },
  {
    "path": "backend/src/api/board-hierarchy-get.ts",
    "content": "import { ok } from \"typera-common/response\"\nimport { Board, Item } from \"../../../common/src/domain\"\nimport { apiTokenHeader, checkBoardAPIAccess, route } from \"./utils\"\nimport { augmentBoardWithCRDT } from \"../../../common/src/board-crdt-helper\"\nimport { yWebSocketServer } from \"../board-yjs-server\"\n\n/**\n * Gets board current contents\n *\n * @tags Board\n */\nexport const boardHierarchyGet = route\n    .get(\"/api/v1/board/:boardId/hierarchy\")\n    .use(apiTokenHeader)\n    .handler((request) =>\n        checkBoardAPIAccess(request, async (boardState) => {\n            const board = augmentBoardWithCRDT(\n                await yWebSocketServer.docs.getYDocAndWaitForFetch(boardState.board.id),\n                boardState.board,\n            )\n            return ok({ board: getBoardHierarchy(board) })\n        }),\n    )\n\nexport type ItemHierarchy = Item & { children: ItemHierarchy[] }\nexport function getBoardHierarchy(board: Board) {\n    const allItems = Object.values(board.items)\n    const rootItems = allItems.filter((i) => i.containerId === undefined).map(getItemHierarchy(allItems))\n    return { ...board, items: rootItems }\n}\nconst getItemHierarchy = (items: Item[]) => (item: Item): ItemHierarchy => {\n    const children: ItemHierarchy[] = items.filter((i) => i.containerId === item.id).map(getItemHierarchy(items))\n    return { ...item, children }\n}\n"
  },
  {
    "path": "backend/src/api/board-history-get.ts",
    "content": "import { ok, streamingBody } from \"typera-common/response\"\nimport { getFullBoardHistory } from \"../board-store\"\nimport { withDBClient } from \"../db\"\nimport { apiTokenHeader, checkBoardAPIAccess, route } from \"./utils\"\n\n/**\n * List the history of a board\n *\n * @tags Board\n */\nexport const boardHistoryGet = route\n    .get(\"/api/v1/board/:boardId/history\")\n    .use(apiTokenHeader)\n    .handler((request) =>\n        checkBoardAPIAccess(request, async (board) => {\n            return ok(\n                streamingJSONBody(\"history\", async (callback) => {\n                    await withDBClient(\n                        async (client) =>\n                            await getFullBoardHistory(board.board.id, client, (bundle) => bundle.forEach(callback)),\n                    )\n                }),\n            )\n        }),\n    )\n\nfunction streamingJSONBody(fieldName: string, generator: (callback: (item: any) => void) => Promise<void>) {\n    return streamingBody(async (stream) => {\n        // Due to memory concerns we fetch board histories from DB as chunks, so this API\n        // response must also be chunked\n        try {\n            stream.write(`{\"${fieldName}\":[`)\n            let chunksProcessed = 0\n            await generator((item) => {\n                let prefix = chunksProcessed === 0 ? \"\" : \",\"\n                stream.write(`${prefix}${JSON.stringify(item)}`)\n                chunksProcessed++\n            })\n            stream.write(\"]}\")\n            stream.end()\n            //console.log(`Wrote ${chunksProcessed} chunks`)\n        } catch (e) {\n            console.error(`Error writing a streamed body: ${e}`)\n            stream.end()\n        }\n    })\n}\n"
  },
  {
    "path": "backend/src/api/board-update.ts",
    "content": "import * as t from \"io-ts\"\nimport { NonEmptyString } from \"io-ts-types\"\nimport { ok } from \"typera-common/response\"\nimport { body } from \"typera-express/parser\"\nimport { BoardAccessPolicyCodec } from \"../../../common/src/domain\"\nimport { apiTokenHeader, checkBoardAPIAccess, dispatchSystemAppEvent, route } from \"./utils\"\nimport { renameBoardConvenienceColumnOnly, updateBoardAccessPolicy } from \"../board-store\"\n\n/**\n * Changes board name and, optionally, access policy.\n *\n * @tags Board\n */\nexport const boardUpdate = route\n    .put(\"/api/v1/board/:boardId\")\n    .use(apiTokenHeader, body(t.type({ name: NonEmptyString, accessPolicy: BoardAccessPolicyCodec })))\n    .handler((request) =>\n        checkBoardAPIAccess(request, async (board) => {\n            const { boardId } = request.routeParams\n            const { name, accessPolicy } = request.body\n            await renameBoardConvenienceColumnOnly(boardId, name)\n            dispatchSystemAppEvent(board, { action: \"board.rename\", boardId, name })\n            if (accessPolicy) {\n                await updateBoardAccessPolicy(boardId, accessPolicy)\n                dispatchSystemAppEvent(board, { action: \"board.setAccessPolicy\", boardId, accessPolicy })\n            }\n            return ok({ ok: true })\n        }),\n    )\n"
  },
  {
    "path": "backend/src/api/github-webhook.ts",
    "content": "import { encode as htmlEncode } from \"html-entities\"\nimport * as t from \"io-ts\"\nimport { badRequest, internalServerError, ok } from \"typera-common/response\"\nimport { body } from \"typera-express/parser\"\nimport { RED, YELLOW } from \"../../../common/src/colors\"\nimport { Note } from \"../../../common/src/domain\"\nimport { getBoard } from \"../board-state\"\nimport { addItem, dispatchSystemAppEvent, InvalidRequest, route } from \"./utils\"\n\n// TODO: require API_TOKEN header for github too!\n/**\n * GitHub webhook\n *\n * @tags Webhooks\n */\nexport const githubWebhook = route\n    .post(\"/api/v1/webhook/github/:boardId\")\n    .use(\n        body(\n            t.partial({\n                issue: t.type({\n                    html_url: t.string,\n                    title: t.string,\n                    number: t.number,\n                    state: t.string,\n                    labels: t.array(t.type({ name: t.string })),\n                }),\n            }),\n        ),\n    )\n    .handler(async (request) => {\n        try {\n            const boardId = request.routeParams.boardId\n            const body = request.body\n            const board = await getBoard(boardId)\n            if (!board) {\n                console.warn(`Github webhook call for unknown board ${boardId}`)\n                return ok()\n            }\n            if (body.issue) {\n                const url = body.issue.html_url\n                const title = body.issue.title\n                const number = body.issue.number.toString()\n                const state = body.issue.state\n                if (state !== \"open\") {\n                    console.log(`Github webhook call board ${boardId}: Item in ${state} state`)\n                } else {\n                    const linkStart = `<a href=${url}>`\n                    const linkHTML = `${linkStart}${htmlEncode(number)}</a> ${htmlEncode(title)}`\n                    const existingItem = Object.values(board.board.items).find(\n                        (i) => i.type === \"note\" && i.text.includes(url),\n                    ) as Note | undefined\n                    const isBug = body.issue.labels.some((l) => l.name === \"bug\")\n                    const color = isBug ? RED : YELLOW\n                    if (!existingItem) {\n                        console.log(`Github webhook call board ${boardId}: New item`)\n                        addItem(board, \"note\", linkHTML, color, \"New issues\")\n                    } else {\n                        console.log(`Github webhook call board ${boardId}: Item exists`)\n                        const updatedItem: Note = { ...existingItem, color }\n                        dispatchSystemAppEvent(board, { action: \"item.update\", boardId, items: [updatedItem] })\n                    }\n                }\n            }\n            return ok()\n        } catch (e) {\n            console.error(e)\n            if (e instanceof InvalidRequest) {\n                return badRequest(e.message)\n            } else {\n                return internalServerError()\n            }\n        }\n    })\n"
  },
  {
    "path": "backend/src/api/item-create-or-update.ts",
    "content": "import * as t from \"io-ts\"\nimport { NonEmptyString } from \"io-ts-types\"\nimport _ from \"lodash\"\nimport { ok } from \"typera-common/response\"\nimport { body } from \"typera-express/parser\"\nimport { Color, isNote, Note } from \"../../../common/src/domain\"\nimport { ServerSideBoardState } from \"../board-state\"\nimport {\n    addItem,\n    apiTokenHeader,\n    checkBoardAPIAccess,\n    dispatchSystemAppEvent,\n    findContainer,\n    getItemAttributesForContainer,\n    InvalidRequest,\n    route,\n} from \"./utils\"\n/**\n * Creates a new item on given board or updates an existing one.\n * If you want to add the item onto a specific area/container element on the board, you can\n * find the id of the container by inspecting with your browser.\n *\n * @tags Board\n */\nexport const itemCreateOrUpdate = route\n    .put(\"/api/v1/board/:boardId/item/:itemId\")\n    .use(\n        apiTokenHeader,\n        body(\n            t.intersection([\n                t.type({\n                    type: t.literal(\"note\"),\n                    text: NonEmptyString,\n                    color: t.string,\n                }),\n                t.partial({\n                    container: t.string,\n                    x: t.number,\n                    y: t.number,\n                    width: t.number,\n                    height: t.number,\n                    replaceTextIfExists: t.boolean,\n                    replaceColorIfExists: t.boolean,\n                    replaceContainerIfExists: t.boolean,\n                }),\n            ]),\n        ),\n    )\n    .handler((request) =>\n        checkBoardAPIAccess(request, async (board) => {\n            const { itemId } = request.routeParams\n            let {\n                type,\n                text,\n                color,\n                container,\n                replaceTextIfExists,\n                replaceColorIfExists,\n                replaceContainerIfExists = true,\n                ...rest\n            } = request.body\n            console.log(`PUT item for board ${board.board.id} item ${itemId}: ${JSON.stringify(request.req.body)}`)\n            const existingItem = board.board.items[itemId]\n            if (existingItem) {\n                updateItem(\n                    board,\n                    rest.x ?? existingItem.x,\n                    rest.y ?? existingItem.y,\n                    type,\n                    text,\n                    color,\n                    container,\n                    rest.width ?? existingItem.width,\n                    rest.height ?? existingItem.height,\n                    itemId,\n                    replaceTextIfExists,\n                    replaceColorIfExists,\n                    replaceContainerIfExists,\n                )\n            } else {\n                console.log(`Adding new item`)\n                addItem(board, type, text, color, container, itemId, rest)\n            }\n            return ok({ ok: true })\n        }),\n    )\n\nfunction updateItem(\n    board: ServerSideBoardState,\n    x: number,\n    y: number,\n    type: \"note\",\n    text: string,\n    color: Color,\n    container: string | undefined,\n    width: number,\n    height: number,\n    itemId: string,\n    replaceTextIfExists: boolean | undefined,\n    replaceColorIfExists: boolean | undefined,\n    replaceContainerIfExists: boolean | undefined,\n) {\n    const existingItem = board.board.items[itemId]\n\n    if (!isNote(existingItem)) {\n        throw new InvalidRequest(\"Unexpected item type\")\n    }\n\n    let updatedItem: Note = {\n        ...existingItem,\n        x: x !== undefined ? x : existingItem.x,\n        y: y !== undefined ? y : existingItem.y,\n        text: replaceTextIfExists !== false ? text : existingItem.text,\n        color: replaceColorIfExists !== false ? color || existingItem.color : existingItem.color,\n        width: width !== undefined ? width : existingItem.width,\n        height: height !== undefined ? height : existingItem.height,\n    }\n\n    if (container && replaceContainerIfExists !== false) {\n        const containerItem = findContainer(container, board.board)\n        const currentContainer = findContainer(existingItem.containerId, board.board)\n        const containerAttrs =\n            containerItem !== currentContainer ? getItemAttributesForContainer(container, board.board) : {}\n\n        updatedItem = {\n            ...updatedItem,\n            ...containerAttrs,\n        }\n    }\n\n    if (!_.isEqual(updatedItem, existingItem)) {\n        console.log(`Updating existing item`)\n        dispatchSystemAppEvent(board, { action: \"item.update\", boardId: board.board.id, items: [updatedItem] })\n    } else {\n        console.log(`Not updating: item not changed`)\n    }\n}\n"
  },
  {
    "path": "backend/src/api/item-create.ts",
    "content": "import * as t from \"io-ts\"\nimport { ok } from \"typera-common/response\"\nimport { body } from \"typera-express/parser\"\nimport { addItem, apiTokenHeader, checkBoardAPIAccess, route } from \"./utils\"\n\n/**\n * Creates a new item on given board. If you want to add the item onto a\n * specific area/container element on the board, you can find the id of the\n * container by inspecting with your browser.\n *\n * @tags Board\n */\nexport const itemCreate = route\n    .post(\"/api/v1/board/:boardId/item\")\n    .use(\n        apiTokenHeader,\n        body(\n            t.intersection([\n                t.type({\n                    type: t.literal(\"note\"),\n                    text: t.string,\n                    color: t.string,\n                }),\n                t.partial({\n                    container: t.string,\n                    x: t.number,\n                    y: t.number,\n                    width: t.number,\n                    height: t.number,\n                }),\n            ]),\n        ),\n    )\n    .handler((request) =>\n        checkBoardAPIAccess(request, async (board) => {\n            const { type, text, color, container, ...rest } = request.body\n            console.log(`POST item for board ${board.board.id}: ${JSON.stringify(request.req.body)}`)\n            const item = addItem(board, type, text, color, container, undefined, rest)\n            return ok(item)\n        }),\n    )\n"
  },
  {
    "path": "backend/src/api/utils.ts",
    "content": "import * as bodyParser from \"body-parser\"\nimport * as t from \"io-ts\"\nimport { badRequest, internalServerError, notFound } from \"typera-common/response\"\nimport { applyMiddleware } from \"typera-express\"\nimport { wrapNative } from \"typera-express/middleware\"\nimport { headers } from \"typera-express/parser\"\nimport { DEFAULT_NOTE_COLOR } from \"../../../common/src/colors\"\nimport {\n    AppEvent,\n    Board,\n    BoardHistoryEntry,\n    Color,\n    Container,\n    EventUserInfo,\n    newISOTimeStamp,\n    newNote,\n    Note,\n    PersistableBoardItemEvent,\n} from \"../../../common/src/domain\"\nimport { getBoard, ServerSideBoardState, updateBoards } from \"../board-state\"\nimport { broadcastBoardEvent } from \"../websocket-sessions\"\n\nexport const route = applyMiddleware(wrapNative(bodyParser.json()))\nexport const apiTokenHeader = headers(t.partial({ API_TOKEN: t.string }))\n\nexport async function checkBoardAPIAccess<T>(\n    request: { routeParams: { boardId: string }; headers: { API_TOKEN?: string | undefined } },\n    fn: (board: ServerSideBoardState) => Promise<T>,\n) {\n    const boardId = request.routeParams.boardId\n    const apiToken = request.headers.API_TOKEN\n    try {\n        const board = await getBoard(boardId)\n        if (!board) return notFound()\n        if (board.board.accessPolicy || board.accessTokens.length) {\n            if (!apiToken) {\n                return badRequest(\"API_TOKEN header is missing\")\n            }\n            if (!board.accessTokens.some((t) => t === apiToken)) {\n                console.log(`API_TOKEN ${apiToken} not on list ${board.accessTokens}`)\n                return badRequest(\"Invalid API_TOKEN\")\n            }\n        }\n        return await fn(board)\n    } catch (e) {\n        console.error(e)\n        if (e instanceof InvalidRequest) {\n            return badRequest(e.message)\n        } else {\n            return internalServerError()\n        }\n    }\n}\n\nexport function findContainer(container: string | undefined, board: Board): Container | null {\n    if (container !== undefined) {\n        if (typeof container !== \"string\") {\n            throw new InvalidRequest(\"Expecting container to be undefined, or an id or name of an Container item\")\n        }\n        const containerItem = Object.values(board.items).find(\n            (i) => i.type === \"container\" && (i.text.toLowerCase() === container.toLowerCase() || i.id === container),\n        )\n        if (!containerItem) {\n            throw new InvalidRequest(`Container \"${container}\" not found by id or name`)\n        }\n        return containerItem as Container\n    } else {\n        return null\n    }\n}\n\nexport function getItemAttributesForContainer(container: string | undefined, board: Board) {\n    const containerItem = findContainer(container, board)\n    if (containerItem) {\n        return {\n            containerId: containerItem.id,\n            x: containerItem.x + 2,\n            y: containerItem.y + 2,\n        }\n    }\n    return {}\n}\n\nexport function dispatchSystemAppEvent(board: ServerSideBoardState, appEvent: PersistableBoardItemEvent) {\n    const user: EventUserInfo = { userType: \"system\", nickname: \"Github webhook\" }\n    let historyEntry: BoardHistoryEntry = { ...appEvent, user, timestamp: newISOTimeStamp() }\n    console.log(JSON.stringify(historyEntry))\n    // TODO: refactor, this is the same sequence as done in connection-handler for messages from clients\n    const serial = updateBoards(board, historyEntry)\n    historyEntry = { ...historyEntry, serial }\n    broadcastBoardEvent(historyEntry)\n}\n\nexport function addItem(\n    board: ServerSideBoardState,\n    type: \"note\",\n    text: string,\n    color: Color,\n    container: string | undefined,\n    itemId?: string,\n    partialParams?: Partial<{ x: number; y: number; width: number; height: number }>,\n) {\n    if (type !== \"note\") throw new InvalidRequest(\"Expecting type: note\")\n    if (typeof text !== \"string\" || text.length === 0) throw new InvalidRequest(\"Expecting non zero-length text\")\n\n    let itemAttributes: object = getItemAttributesForContainer(container, board.board)\n    if (itemId) itemAttributes = { ...itemAttributes, id: itemId }\n\n    // Merge partial parameters with existing attributes\n    if (partialParams) {\n        itemAttributes = { ...itemAttributes, ...partialParams }\n    }\n\n    const item: Note = { ...newNote(text, color || DEFAULT_NOTE_COLOR), ...itemAttributes }\n    const appEvent: AppEvent = { action: \"item.add\", boardId: board.board.id, items: [item], connections: [] }\n    dispatchSystemAppEvent(board, appEvent)\n    return item\n}\n\nexport class InvalidRequest extends Error {\n    constructor(message: string) {\n        super(message)\n    }\n}\n"
  },
  {
    "path": "backend/src/board-event-handler.ts",
    "content": "import {\n    AppEvent,\n    BoardHistoryEntry,\n    canWrite,\n    checkBoardAccess,\n    Id,\n    isBoardItemEvent,\n    isPersistableBoardItemEvent,\n    newISOTimeStamp,\n} from \"../../common/src/domain\"\nimport { getBoard, maybeGetBoard, updateBoards } from \"./board-state\"\nimport { getBoardInfo, renameBoardConvenienceColumnOnly, updateBoardAccessPolicy } from \"./board-store\"\nimport { handleCommonEvent } from \"./common-event-handler\"\nimport { MessageHandlerResult } from \"./connection-handler\"\nimport { WS_HOST_DEFAULT, WS_HOST_LOCAL, WS_PROTOCOL } from \"./host-config\"\nimport { obtainLock } from \"./locker\"\nimport { associateUserWithBoard } from \"./user-store\"\nimport { addSessionToBoard, broadcastBoardEvent, getSession } from \"./websocket-sessions\"\nimport { toBuffer, WsWrapper } from \"./ws-wrapper\"\n\nexport const handleBoardEvent = (allowedBoardId: Id | null, getSignedPutUrl: (key: string) => string) => async (\n    socket: WsWrapper,\n    appEvent: AppEvent,\n): Promise<MessageHandlerResult> => {\n    if (await handleCommonEvent(socket, appEvent)) return true\n    const session = getSession(socket)\n    if (!session) {\n        console.error(\"Session missing for socket \" + socket.id)\n        return true\n    }\n    if (appEvent.action === \"board.join\") {\n        //await sleep(3000) // simulate latency\n\n        const boardInfo = await getBoardInfo(appEvent.boardId)\n        if (!boardInfo) {\n            console.warn(`Trying to join unknown board ${appEvent.boardId}`)\n            session.sendEvent({\n                action: \"board.join.denied\",\n                boardId: appEvent.boardId,\n                reason: \"not found\",\n            })\n            return true\n        }\n        const wsHost = boardInfo.ws_host ?? WS_HOST_DEFAULT\n\n        if (!allowedBoardId || appEvent.boardId !== allowedBoardId || !WS_HOST_LOCAL.includes(wsHost)) {\n            // Path - board id mismatch -> always redirect\n\n            const wsAddress = `${WS_PROTOCOL}://${wsHost}/socket/board/${appEvent.boardId}`\n            /* console.info(\n                    `Trying to join board ${appEvent.boardId} on socket for board ${allowedBoardId}, board host ${wsHost} local hostnames ${WS_HOST_LOCAL}`,\n            )*/\n\n            session.sendEvent({\n                action: \"board.join.denied\",\n                boardId: appEvent.boardId,\n                reason: \"redirect\",\n                wsAddress,\n            })\n            return true\n        }\n\n        let board = (await getBoard(appEvent.boardId))!\n\n        const accessPolicy = board.board.accessPolicy\n        const accessLevel = checkBoardAccess(accessPolicy, session.userInfo)\n        if (session.userInfo.userType === \"authenticated\") {\n            if (accessLevel === \"none\") {\n                console.warn(\"Access denied to board by user not on allowList\")\n                session.sendEvent({\n                    action: \"board.join.denied\",\n                    boardId: appEvent.boardId,\n                    reason: \"forbidden\",\n                })\n                return true\n            } else {\n                await associateUserWithBoard(session.userInfo.userId, appEvent.boardId)\n            }\n        } else {\n            if (accessLevel === \"none\") {\n                console.warn(\"Access denied to board by anonymous user\")\n                session.sendEvent({\n                    action: \"board.join.denied\",\n                    boardId: appEvent.boardId,\n                    reason: \"unauthorized\",\n                })\n                return true\n            }\n        }\n        await addSessionToBoard(board, socket, accessLevel, appEvent.initAtSerial)\n        return true\n    }\n\n    if (!session.boardSession) {\n        console.warn(\"Trying to send event to board without session\", appEvent)\n        return true\n    }\n\n    if (isBoardItemEvent(appEvent)) {\n        const boardId = appEvent.boardId\n        const state = await getBoard(boardId)\n        if (!state) {\n            return true // Just ignoring for now, see above todo\n        }\n        if (!canWrite(session.boardSession.accessLevel)) {\n            console.warn(\"Trying to change read-only board\")\n            return true\n        }\n        obtainLock(state.locks, appEvent, socket) // Allow even if was locked (offline use)\n        if (isPersistableBoardItemEvent(appEvent)) {\n            if (!session.isOnBoard(appEvent.boardId)) {\n                console.warn(\"Trying to send event to board without valid session\")\n            } else {\n                let historyEntry: BoardHistoryEntry = {\n                    ...appEvent,\n                    user: session.userInfo,\n                    timestamp: newISOTimeStamp(),\n                }\n                try {\n                    const serial = updateBoards(state, historyEntry)\n                    historyEntry = { ...historyEntry, serial }\n                    broadcastBoardEvent(historyEntry, session)\n                    if (appEvent.action === \"board.rename\") {\n                        // special case: keeping name up to date as it's in a separate column\n                        await renameBoardConvenienceColumnOnly(appEvent.boardId, appEvent.name)\n                    }\n                    if (appEvent.action === \"board.setAccessPolicy\") {\n                        if (session.boardSession.accessLevel !== \"admin\") {\n                            console.warn(\"Trying to change access policy without admin access\")\n                            return true\n                        }\n                        await updateBoardAccessPolicy(appEvent.boardId, appEvent.accessPolicy)\n                    }\n                    return { boardId, serial }\n                } catch (e) {\n                    console.warn(`Error applying event ${JSON.stringify(appEvent)}: ${e} -> forcing board refresh`)\n                    session.sendEvent({ action: \"board.action.apply.failed\" })\n                    return true\n                }\n            }\n        } else if (appEvent.action === \"item.unlock\") {\n            return true\n        }\n    } else {\n        switch (appEvent.action) {\n            case \"cursor.move\": {\n                const { boardId, position } = appEvent\n                const { x, y } = position\n                const state = maybeGetBoard(boardId)\n                if (state) {\n                    const session = getSession(socket)\n                    if (session && session.isOnBoard(appEvent.boardId)) {\n                        state.cursorPositions[socket.id] = { x, y, sessionId: socket.id }\n                        state.cursorsMoved = true\n                    }\n                }\n                return true\n            }\n            case \"user.bringAllToMe\": {\n                const session = getSession(socket)\n                const state = maybeGetBoard(appEvent.boardId)\n                if (session && state && session.isOnBoard(appEvent.boardId)) {\n                    if (session.sessionId !== appEvent.sessionId) {\n                        console.warn(\"Incorrect sessionId in user.bringAllToMe\")\n                    } else {\n                        broadcastBoardEvent(appEvent, session)\n                    }\n                }\n                return true\n            }\n            case \"asset.put.request\": {\n                const { assetId } = appEvent\n                const signedUrl = getSignedPutUrl(assetId)\n                socket.send(toBuffer({ action: \"asset.put.response\", assetId, signedUrl }))\n                return true\n            }\n        }\n    }\n    return false\n}\n"
  },
  {
    "path": "backend/src/board-state.test.ts",
    "content": "import { describe, expect, it } from \"vitest\"\n\ndescribe(\"board state iteration\", () => {\n    it(\"is safe\", () => {\n        // Checking that map value iteration is safe when deleteting items on the way\n        const boards = new Map<number, number>()\n        boards.set(1, 1)\n        boards.set(2, 2)\n        const results: number[] = []\n        for (let b of boards.values()) {\n            results.push(b)\n            boards.delete(b)\n        }\n        expect(results).toEqual([1, 2])\n    })\n})\n"
  },
  {
    "path": "backend/src/board-state.ts",
    "content": "import { merge } from \"lodash\"\nimport { boardReducer } from \"../../common/src/board-reducer\"\nimport { Board, BoardCursorPositions, BoardHistoryEntry, Id } from \"../../common/src/domain\"\nimport { sleep } from \"../../common/src/sleep\"\nimport { createAccessToken, createBoard, fetchBoard, storeEventHistoryBundle } from \"./board-store\"\nimport { quickCompactBoardHistory } from \"./compact-history\"\nimport { Locks } from \"./locker\"\nimport { UserSession, broadcastItemLocks, getBoardSessionCount, getSessionCount } from \"./websocket-sessions\"\nimport * as Y from \"yjs\"\nimport { inTransaction } from \"./db\"\n\n// A mutable state object for server side state\nexport type ServerSideBoardState = {\n    ready: true\n    board: Board\n    recentEvents: BoardHistoryEntry[]\n    recentCrdtUpdate: Uint8Array | null\n    currentlyStoring: {\n        events: BoardHistoryEntry[]\n        crdtUpdate: Uint8Array | null\n    } | null\n    locks: ReturnType<typeof Locks>\n    cursorsMoved: boolean\n    cursorPositions: BoardCursorPositions\n    accessTokens: string[]\n    sessions: UserSession[]\n}\n\nexport type ServerSideBoardStateInternal =\n    | ServerSideBoardState\n    | {\n          ready: false\n          fetch: Promise<ServerSideBoardState | null>\n      }\n\nlet boards: Map<Id, ServerSideBoardStateInternal> = new Map()\n\nexport async function getBoard(id: Id): Promise<ServerSideBoardState | null> {\n    let state = boards.get(id)\n    if (!state) {\n        console.log(`Loading board ${id} into memory`)\n        const fetchState = async () => {\n            const boardData = await fetchBoard(id)\n            if (!boardData) return null\n            const { board, accessTokens } = boardData\n            return {\n                ready: true,\n                board,\n                accessTokens,\n                recentEvents: [],\n                recentCrdtUpdate: null,\n                currentlyStoring: null,\n                locks: Locks((changedLocks) => broadcastItemLocks(id, changedLocks)),\n                cursorsMoved: false,\n                cursorPositions: {},\n                sessions: [],\n            } as ServerSideBoardState\n        }\n        const fetch = fetchState()\n        const temporaryState = {\n            ready: false as const,\n            fetch,\n        }\n        boards.set(id, temporaryState)\n        try {\n            const finalState = await fetch\n            if (!finalState) {\n                boards.delete(id)\n                return null\n            } else {\n                boards.set(id, finalState)\n                console.log(`Board loaded into memory: ${id}`)\n                return finalState\n            }\n        } catch (e) {\n            boards.delete(id)\n            // TODO: avoid retry loop\n            console.error(`Board load failed for board ${id}`)\n            throw e\n        }\n    } else if (!state.ready) {\n        return await state.fetch\n    } else {\n        return state\n    }\n}\n\nexport function maybeGetBoard(id: Id): ServerSideBoardState | undefined {\n    const state = boards.get(id)\n    if (state?.ready) return state\n}\n\nexport function updateBoards(boardState: ServerSideBoardState, appEvent: BoardHistoryEntry) {\n    const currentSerial = boardState.board.serial\n    const serial = currentSerial + 1\n    if (appEvent.serial !== undefined) {\n        throw Error(\"Event already has serial\")\n    }\n    const eventWithSerial = { ...appEvent, serial }\n\n    const updatedBoard = boardReducer(boardState.board, eventWithSerial, { inplace: true, strictOnSerials: true })[0]\n\n    boardState.board = updatedBoard\n    boardState.recentEvents.push(eventWithSerial)\n    return serial\n}\n\nexport function updateBoardCrdt(id: Id, crdtUpdate: Uint8Array) {\n    const boardState = maybeGetBoard(id)\n\n    if (!boardState) {\n        console.warn(\"CRDT update for board not loaded into memory\", id)\n    } else {\n        boardState.recentCrdtUpdate = combineCrdtUpdates(boardState.recentCrdtUpdate, crdtUpdate)\n    }\n}\n\nexport async function addBoard(board: Board, createToken?: boolean): Promise<ServerSideBoardState> {\n    await createBoard(board)\n    const accessTokens = createToken ? [await createAccessToken(board)] : []\n    const boardState = {\n        ready: true as const,\n        board,\n        serial: 0,\n        recentEvents: [],\n        recentCrdtUpdate: null,\n        currentlyStoring: null,\n        locks: Locks((changedLocks) => broadcastItemLocks(board.id, changedLocks)),\n        cursorsMoved: false,\n        cursorPositions: {},\n        accessTokens,\n        sessions: [],\n    }\n    boards.set(board.id, boardState)\n    return boardState\n}\n\nexport function getActiveBoards() {\n    return [...boards.values()].filter((b) => b.ready) as ServerSideBoardState[]\n}\n\nlet savingPromise: Promise<void> = saveBoards()\n\nasync function saveBoards() {\n    await sleep(1000)\n    for (let state of boards.values()) {\n        if (state.ready) await saveBoardChanges(state)\n    }\n    savingPromise = saveBoards()\n}\n\nexport async function awaitSavingChanges() {\n    await savingPromise\n}\n\nasync function saveBoardChanges(state: ServerSideBoardState) {\n    if (state.recentEvents.length > 0 || state.recentCrdtUpdate !== null) {\n        if (state.currentlyStoring) {\n            throw Error(\"Invariant failed: storingEvents not empty\")\n        }\n        const events = state.recentEvents.splice(0)\n        const crdtUpdate = state.recentCrdtUpdate\n        state.currentlyStoring = {\n            events,\n            crdtUpdate,\n        }\n        state.recentCrdtUpdate = null\n        console.log(\n            `Saving board ${state.board.id} at serial ${state.board.serial} with ${\n                state.currentlyStoring.events.length\n            } new events ${crdtUpdate ? \"and CRDT update of size \" + crdtUpdate.length : \"\"}`,\n        )\n        const lastSerial = state.board.serial\n        try {\n            await inTransaction((client) =>\n                storeEventHistoryBundle(state.board.id, events, lastSerial, crdtUpdate, client),\n            )\n        } catch (e) {\n            // Push event back to the head of save list for retrying later\n            state.recentEvents = [...state.currentlyStoring.events, ...state.recentEvents]\n            state.recentCrdtUpdate = merge(state.currentlyStoring.crdtUpdate, state.recentCrdtUpdate)\n            console.error(\"Board save failed for board\", state.board.id, e)\n        }\n        state.currentlyStoring = null\n    }\n    if (state.recentEvents.length === 0 && getBoardSessionCount(state.board.id) === 0) {\n        console.log(`Purging board ${state.board.id} from memory`)\n        boards.delete(state.board.id)\n        await quickCompactBoardHistory(state.board.id)\n    }\n}\n\nexport function combineCrdtUpdates(a: Uint8Array | null, b: Uint8Array | null) {\n    if (!a) return b\n    if (!b) return a\n    return Y.mergeUpdates([a, b])\n}\n\nexport function getActiveBoardCount() {\n    return boards.size\n}\n\nsetInterval(() => {\n    console.log(\"Statistics: active boards \" + getActiveBoardCount() + \", sessions \" + getSessionCount())\n}, 60000)\n"
  },
  {
    "path": "backend/src/board-store.ts",
    "content": "import { PoolClient } from \"pg\"\nimport QueryStream from \"pg-query-stream\"\nimport * as uuid from \"uuid\"\nimport { boardReducer } from \"../../common/src/board-reducer\"\nimport { Board, BoardAccessPolicy, BoardHistoryEntry, Id, isBoardEmpty, Serial } from \"../../common/src/domain\"\nimport { migrateBoard, migrateEvent, mkBootStrapEvent } from \"../../common/src/migration\"\nimport { inTransaction, withDBClient } from \"./db\"\nimport { assertNotNull } from \"../../common/src/assertNotNull\"\n\nexport type BoardAndAccessTokens = {\n    board: Board\n    accessTokens: string[]\n}\n\nexport type BoardInfo = {\n    id: Id\n    name: string\n    ws_host: string | null\n}\n\nexport async function getBoardInfo(id: Id): Promise<BoardInfo | null> {\n    const result = await withDBClient((client) => client.query(\"SELECT id, name, ws_host FROM board WHERE id=$1\", [id]))\n    return result.rows.length === 1 ? (result.rows[0] as BoardInfo) : null\n}\n\nconst selectBoardQuery = `\nselect id,\n    jsonb_set (content - 'accessPolicy', '{accessPolicy}', \n    \t\tcast(json_build_object(\n    \t\t\t'allowList', (    \t\t\t\n    \t\t\t\tcoalesce((\n\t\t\t\t\t  select jsonb_agg(jsonb_strip_nulls(jsonb_build_object('domain', domain, 'access', access, 'email', email))) \n\t\t\t\t\t  from board_access\n\t\t\t\t\t  where board_access.board_id = board.id\n\t\t\t\t\t), '[]')    \t\t\t\n    \t\t\t), \n    \t\t\t'publicRead', public_read, \n    \t\t\t'publicWrite', public_write\n    \t\t) as jsonb)) \n    \t\tas content\nfrom board\nwhere id=$1\n`\n\nexport async function fetchBoard(id: Id): Promise<BoardAndAccessTokens | null> {\n    return await inTransaction(async (client) => {\n        const started = new Date().getTime()\n        const result = await client.query(selectBoardQuery, [id])\n        if (result.rows.length == 0) {\n            return null\n        } else {\n            const snapshot = result.rows[0].content as Board\n            if (\n                snapshot.accessPolicy &&\n                snapshot.accessPolicy.allowList.length === 0 &&\n                snapshot.accessPolicy.publicRead &&\n                snapshot.accessPolicy.publicWrite\n            ) {\n                // Effectively no access policy\n                delete snapshot.accessPolicy\n            }\n            let historyEventCount = 0\n            let lastSerial = 0\n            let board = snapshot\n\n            let i = 0\n            let rebuildingSnapshot = false\n            function updateBoardWithEventChunk(chunk: BoardHistoryEntry[]) {\n                if (chunk.length === 0) {\n                    return // CRDT-only bundle\n                }\n                board = chunk.reduce((b, e) => {\n                    i++\n                    if (e.action === \"board.setAccessPolicy\") {\n                        // Don't process access policy event when fetching board\n                        // Access policy may have been changed in the database after the event\n                        // And the board table status is considered the master\n                        return { ...b, serial: assertNotNull(e.serial) }\n                    }\n                    return boardReducer(b, e, { inplace: true, strictOnSerials: !rebuildingSnapshot })[0]\n                }, board)\n                historyEventCount += chunk.length\n                lastSerial = chunk[chunk.length - 1].serial ?? snapshot.serial\n            }\n\n            await getBoardHistory(id, snapshot.serial, updateBoardWithEventChunk).catch(async (error) => {\n                console.error(error.message)\n                console.error(\n                    `Error applying board history for snapshot update for board ${id}. Loop index ${i}. Rebooting snapshot. This may be a lossy operation.`,\n                )\n                i = 0\n                board = { ...snapshot, items: {}, connections: [] }\n                rebuildingSnapshot = true\n                try {\n                    await getFullBoardHistory(id, client, updateBoardWithEventChunk)\n                } catch (e) {\n                    console.error(`Unable to reboot snapshot, failing at loop index ${i}. Giving up.`)\n\n                    // TODO: this board cannot be repaired automatically. We should block usage, or it will be\n                    // and endless loop. Local dev, board ee803db1-f41a-43c6-9e39-83057faace60.\n\n                    throw e\n                }\n            })\n\n            const serial = (historyEventCount > 0 ? lastSerial : snapshot.serial) || 0\n            const elapsed = new Date().getTime() - started\n            console.log(\n                `Loaded board ${id} at serial ${serial} from snapshot at serial ${snapshot.serial} and ${historyEventCount} events after snapshot. Took ${elapsed}ms`,\n            )\n\n            if (historyEventCount > 1000 || rebuildingSnapshot /* time to create a new snapshot*/) {\n                console.log(\n                    `Saving snapshot for board ${id} at serial ${serial}/${snapshot.serial} with ${historyEventCount} new events`,\n                )\n                await saveBoardSnapshot(mkSnapshot(board, serial), client)\n            }\n            const accessTokens = (\n                await client.query(\"SELECT token FROM board_api_token WHERE board_id=$1\", [id])\n            ).rows.map((row) => row.token)\n\n            return { board: { ...board, serial }, accessTokens }\n        }\n    })\n}\n\nexport async function createBoard(board: Board): Promise<void> {\n    await inTransaction(async (client) => {\n        const result = await client.query(\"SELECT id FROM board WHERE id=$1\", [board.id])\n        if (result.rows.length > 0) throw Error(\"Board already exists: \" + board.id)\n        await client.query(`INSERT INTO board(id, name, content) VALUES ($1, $2, $3)`, [\n            board.id,\n            board.name,\n            mkSnapshot(board, 0),\n        ])\n\n        await updateAccessPolicy(board.id, board.accessPolicy, client)\n\n        if (!isBoardEmpty(board)) {\n            console.log(`Creating non-empty board ${board.id} -> bootstrapping history`)\n            const event = mkBootStrapEvent(board.id, board)\n            storeEventHistoryBundle(board.id, [event], event.serial!, null, client)\n        }\n    })\n}\n\nasync function updateAccessPolicy(boardId: string, accessPolicy: BoardAccessPolicy, client: PoolClient): Promise<void> {\n    const publicRead = accessPolicy ? !!accessPolicy.publicRead : true\n    const publicWrite = accessPolicy ? !!accessPolicy.publicWrite : true\n    await client.query(`UPDATE board SET public_read=$1, public_write=$2 WHERE id=$3`, [\n        publicRead,\n        publicWrite,\n        boardId,\n    ])\n    await client.query(`DELETE FROM board_access WHERE board_id=$1`, [boardId])\n    if (accessPolicy) {\n        for (const entry of accessPolicy.allowList) {\n            const domain = \"domain\" in entry ? entry.domain : null\n            const email = \"email\" in entry ? entry.email : null\n            await client.query(`INSERT INTO BOARD_access (board_id, domain, email, access) VALUES ($1, $2, $3, $4)`, [\n                boardId,\n                domain,\n                email,\n                entry.access,\n            ])\n        }\n    }\n}\n\n// Updates the name column, which is there for admin convenience (not used by application logic)\nexport async function renameBoardConvenienceColumnOnly(boardId: Id, name: string) {\n    await inTransaction(async (client) => {\n        const result = await client.query(\"SELECT content FROM board WHERE id=$1\", [boardId])\n        if (result.rows.length !== 1) throw Error(\"Board not found: \" + boardId)\n        await client.query(\"UPDATE board SET name=$1 WHERE id=$2\", [name, boardId])\n    })\n}\n\nexport async function updateBoardAccessPolicy(boardId: Id, accessPolicy: BoardAccessPolicy) {\n    await inTransaction(async (client) => {\n        const result = await client.query(\"SELECT content FROM board WHERE id=$1\", [boardId])\n        if (result.rows.length !== 1) throw Error(\"Board not found: \" + boardId)\n        await updateAccessPolicy(boardId, accessPolicy, client)\n    })\n}\n\nexport async function createAccessToken(board: Board): Promise<string> {\n    const token = uuid.v4()\n    await inTransaction(async (client) =>\n        client.query(\"INSERT INTO board_api_token (board_id, token) VALUES ($1, $2)\", [board.id, token]),\n    )\n    return token\n}\n\ntype StreamingBoardEventCallback = (chunk: BoardHistoryEntry[]) => void\n\n// Due to memory concerns we fetch board histories from DB as chunks,\n// which are currently implemented as sort of a poor-man's observable\nfunction streamingBoardEventsQuery(text: string, values: any[], client: PoolClient, cb: StreamingBoardEventCallback) {\n    return new Promise((resolve, reject) => {\n        const query = new QueryStream(text, values)\n        const stream = client.query(query)\n        stream.on(\"error\", reject)\n        stream.on(\"end\", resolve)\n        stream.on(\"data\", (row) => {\n            try {\n                const chunk = row.events?.events as BoardHistoryEntry[] | undefined\n\n                if (!chunk) {\n                    throw Error(`Unexpected DB row value ${chunk}`)\n                }\n\n                cb(chunk.map(migrateEvent))\n            } catch (error) {\n                console.error(error)\n                stream.destroy()\n                reject(error)\n            }\n        })\n    })\n}\n\nexport function getFullBoardHistory(id: Id, client: PoolClient, cb: StreamingBoardEventCallback) {\n    return streamingBoardEventsQuery(\n        `\n        SELECT events \n        FROM board_event \n        WHERE board_id=$1 \n        ORDER BY last_serial, first_serial\n        `,\n        [id],\n        client,\n        cb,\n    )\n}\n\nexport async function getBoardHistory(id: Id, afterSerial: Serial, cb: StreamingBoardEventCallback): Promise<void> {\n    await withDBClient(async (client) => {\n        let firstSerial = -1\n        let lastSerial = -1\n        let firstValidSerial = -1\n        await streamingBoardEventsQuery(\n            `\n            SELECT events \n            FROM board_event \n            WHERE board_id=$1 AND last_serial >= $2 \n            ORDER BY last_serial, first_serial\n            `,\n            [id, afterSerial],\n            client,\n            (chunk) => {\n                if (chunk.length === 0) {\n                    return // CRDT-only bundle\n                }\n                if (firstSerial === -1 && typeof chunk[0]?.serial === \"number\") {\n                    firstSerial = chunk[0]?.serial\n                }\n                lastSerial = chunk[chunk.length - 1].serial ?? -1\n\n                const validEventsAfter = chunk.filter((r) => r.serial! > afterSerial)\n                if (validEventsAfter.length === 0) {\n                    // Got chunk where no events have serial greater than the snapshot point -- discard it\n                    return\n                }\n\n                if (firstValidSerial === -1 && typeof validEventsAfter[0].serial === \"number\") {\n                    firstValidSerial = validEventsAfter[0].serial\n                }\n                cb(validEventsAfter)\n                return\n            },\n        )\n\n        // Client is up to date, ok\n        if (lastSerial === afterSerial) {\n            return\n        }\n\n        // Found continuous history, ok\n        if (firstValidSerial === afterSerial + 1) {\n            return\n        }\n\n        if (firstValidSerial === -1) {\n            if (afterSerial === 0) {\n                // Requesting from start, zero events found, is ok\n                return\n            }\n            // Client claims to be in the future, not ok\n            throw Error(\n                `Cannot find history to start after the requested serial ${afterSerial} for board ${id}. Seems like the requested serial is higher than currently stored in DB`,\n            )\n        }\n\n        // Found noncontinuous event timeline, not ok\n        throw Error(\n            `Cannot find history to start after the requested serial ${afterSerial} for board ${id}. Found history for ${firstValidSerial}..${lastSerial}`,\n        )\n    })\n}\n\nexport function verifyContinuity(boardId: Id, init: Serial, ...histories: BoardHistoryEntry[][]) {\n    for (let history of histories) {\n        if (history.length > 0) {\n            if (!verifyTwoPoints(boardId, init, history[0].serial!)) {\n                return false\n            }\n            init = history[history.length - 1].serial!\n        }\n    }\n    return true\n}\n\nexport function verifyEventArrayContinuity(boardId: Id, init: Serial, events: BoardHistoryEntry[]) {\n    for (let event of events) {\n        if (!verifyTwoPoints(boardId, init, event.serial!)) {\n            return false\n        }\n        init = event.serial!\n    }\n    return true\n}\n\nfunction verifyTwoPoints(boardId: Id, a: Serial, b: Serial) {\n    if (b !== a + 1) {\n        console.error(`History discontinuity: ${a} -> ${b} for board ${boardId}`)\n        return false\n    }\n    return true\n}\n\nfunction mkSnapshot(board: Board, serial: Serial) {\n    const { accessPolicy, ...result } = migrateBoard({ ...board, serial })\n    return result\n}\n\nexport async function saveBoardSnapshot(board: Board, client: PoolClient) {\n    console.log(`Save board snapshot ${board.id} at serial ${board.serial}`)\n    client.query(\n        `\n    UPDATE board \n    SET name=$2, content=$3 \n    WHERE id=$1`,\n        [board.id, board.name, board],\n    )\n}\n\nexport async function storeEventHistoryBundle(\n    boardId: Id,\n    events: BoardHistoryEntry[],\n    lastSerial: Serial, // Needed in case events is empty\n    crdtUpdate: Uint8Array | null,\n    client: PoolClient,\n    savedAt = new Date(),\n) {\n    if (events.length === 0 && crdtUpdate === null) {\n        throw Error(\"Trying to store a bundle without events or crdtUpdate\")\n    }\n    if (events[0]?.firstSerial !== undefined) {\n        throw Error(\"Assertion failed: folded events not expected on the server side.\")\n    }\n    const firstSerial = events.length > 0 ? assertNotNull(events[0].serial) : null\n    if (events.length > 0 && events[events.length - 1].serial != lastSerial) {\n        throw Error(\"Serial mismatch between lastSerial and lastEvent.serial\")\n    }\n    await client.query(\n        `\n        INSERT INTO board_event(board_id, first_serial, last_serial, events, crdt_update, saved_at) \n        VALUES ($1, $2, $3, $4, $5, $6)\n        `,\n        [boardId, firstSerial, lastSerial, { events }, crdtUpdate, savedAt],\n    )\n}\n\nexport async function storeCRDTOnlyEventHistoryBundle(\n    boardId: Id,\n    boardSerial: Serial,\n    crdtUpdate: Uint8Array | null,\n    client: PoolClient,\n    savedAt = new Date(),\n) {\n    await client.query(\n        `\n        INSERT INTO board_event(board_id, first_serial, last_serial, events, crdt_update, saved_at) \n        VALUES ($1, $2, $3, $4, $5, $6)\n        `,\n        [boardId, null, boardSerial, null, crdtUpdate, savedAt],\n    )\n}\n\nexport type BoardHistoryBundle = {\n    board_id: Id\n    last_serial: Serial\n    events: {\n        events: BoardHistoryEntry[]\n    }\n    crdt_update: Uint8Array | null\n}\n\nexport async function getBoardHistoryBundlesWithLastSerialsBetween(\n    client: PoolClient,\n    id: Id,\n    lsMin: Serial,\n    lsMax: Serial,\n): Promise<BoardHistoryBundle[]> {\n    return (\n        await client.query(\n            `\n            SELECT board_id, last_serial, events, crdt_update \n            FROM board_event \n            WHERE board_id=$1 AND last_serial >= $2 AND last_serial <= $3 \n            ORDER BY last_serial, first_serial\n            `,\n            [id, lsMin, lsMax],\n        )\n    ).rows.map(migrateBundle)\n}\n\nexport async function getBoardHistoryCrdtUpdates(client: PoolClient, id: Id): Promise<Uint8Array[]> {\n    return (\n        await client.query(\n            `\n            SELECT crdt_update \n            FROM board_event \n            WHERE board_id=$1 AND crdt_update IS NOT NULL \n            ORDER BY last_serial, first_serial\n            `,\n            [id],\n        )\n    ).rows.map((row) => row.crdt_update)\n}\n\nfunction migrateBundle(b: BoardHistoryBundle): BoardHistoryBundle {\n    return { ...b, events: { ...b.events, events: b.events.events.map(migrateEvent) } }\n}\n\nexport type BoardHistoryBundleMeta = {\n    board_id: Id\n    first_serial: Serial | null\n    last_serial: Serial\n    saved_at: Date\n}\n\nexport async function getBoardHistoryBundleMetas(client: PoolClient, id: Id): Promise<BoardHistoryBundleMeta[]> {\n    return (\n        await client.query(\n            `\n            SELECT board_id, last_serial, first_serial, saved_at \n            FROM board_event \n            WHERE board_id=$1 \n            ORDER BY last_serial, first_serial\n            `,\n            [id],\n        )\n    ).rows\n}\n\nexport function verifyContinuityFromMetas(boardId: Id, init: Serial, bundles: BoardHistoryBundleMeta[]) {\n    for (let bundle of bundles) {\n        if (!bundle.first_serial) {\n            // CRDT only bundle\n            if (bundle.last_serial !== init) {\n                console.error(\n                    `History discontinuity:  ${init} -> ${bundle.last_serial} for CRDT-only bundle for board ${boardId}`,\n                )\n                return false\n            }\n        } else {\n            if (!verifyTwoPoints(boardId, init, bundle.first_serial)) {\n                return false\n            }\n            init = bundle.last_serial\n        }\n    }\n    return true\n}\n\nexport async function findAllBoards(client: PoolClient): Promise<Id[]> {\n    const result = await client.query(\"SELECT id FROM board\")\n    return result.rows.map((row) => row.id)\n}\n"
  },
  {
    "path": "backend/src/board-yjs-server.ts",
    "content": "import expressWs from \"express-ws\"\nimport * as Y from \"yjs\"\nimport { updateBoardCrdt } from \"./board-state\"\nimport { getBoardHistoryCrdtUpdates } from \"./board-store\"\nimport { withDBClient } from \"./db\"\nimport { getSessionIdFromCookies } from \"./http-session\"\nimport { getSessionById } from \"./websocket-sessions\"\nimport YWebSocketServer from \"./y-websocket-server/YWebSocketServer\"\nimport * as WebSocket from \"ws\"\nimport { canRead, canWrite } from \"../../common/src/domain\"\n\nconst socketsBySessionId: Record<string, WebSocket[]> = {}\n\nexport function closeYjsSocketsBySessionId(sessionId: string) {\n    const sockets = socketsBySessionId[sessionId]\n    if (sockets) {\n        for (const socket of sockets) {\n            socket.close()\n        }\n        delete socketsBySessionId[sessionId]\n        console.log(\n            `CLOSED ${sockets.length} y.js sockets by session id ${sessionId} - remaining sockets exist for ${\n                Object.keys(socketsBySessionId).length\n            } other sessions`,\n        )\n    }\n}\n\nexport const yWebSocketServer = new YWebSocketServer({\n    persistence: {\n        bindState: async (docName, ydoc) => {\n            const boardId = docName\n            const updates = await withDBClient(async (client) => getBoardHistoryCrdtUpdates(client, boardId))\n\n            if (updates.length === 0) {\n                const initUpdate = Y.encodeStateAsUpdate(ydoc)\n                console.log(`Storing initial CRDT state to DB for board ${boardId}`)\n                updateBoardCrdt(boardId, initUpdate)\n            } else {\n                console.log(`Loaded ${updates.length} CRDT updates from DB for board ${boardId}`)\n                for (const update of updates) {\n                    Y.applyUpdate(ydoc, update)\n                }\n            }\n\n            ydoc.on(\"update\", (update: Uint8Array, origin: any, doc: Y.Doc) => {\n                updateBoardCrdt(boardId, update)\n            })\n        },\n        writeState: async (docName, ydoc) => {\n            // TODO: needed?\n        },\n    },\n})\n\nexport function BoardYJSServer(ws: expressWs.Instance, path: string) {\n    ws.app.ws(path, async (socket, req) => {\n        const boardId = req.params.boardId\n        const sessionId = getSessionIdFromCookies(req)\n        const session = sessionId ? getSessionById(sessionId) : undefined\n        if (\n            !sessionId ||\n            !session ||\n            !session.boardSession ||\n            session.boardSession.boardId !== boardId ||\n            !canRead(session.boardSession.accessLevel)\n        ) {\n            // TODO: implement read-only YJS connections\n            //console.warn(\"No session for YJS connection for board\", boardId)\n            socket.close()\n            return\n        }\n        if (!socketsBySessionId[sessionId]) {\n            socketsBySessionId[sessionId] = []\n        }\n        socketsBySessionId[sessionId].push(socket)\n        console.log(\n            `OPENED y.js connection for session ${sessionId}. Now sockets exist for ${\n                Object.keys(socketsBySessionId).length\n            } sessions`,\n        )\n        socket.addEventListener(\"close\", () => {\n            if (socketsBySessionId[sessionId]) {\n                socketsBySessionId[sessionId] = socketsBySessionId[sessionId].filter((s) => s !== socket)\n                if (socketsBySessionId[sessionId].length === 0) {\n                    delete socketsBySessionId[sessionId]\n                }\n                console.log(\n                    `CLOSED y.js connection. Now sockets exist for ${Object.keys(socketsBySessionId).length} sessions`,\n                )\n            }\n        })\n        const readOnly = !canWrite(session.boardSession.accessLevel)\n        const docName = boardId\n        try {\n            await yWebSocketServer.setupWSConnection(socket, docName, readOnly)\n        } catch (e) {\n            console.error(\"Error setting up YJS connection\", e)\n            socket.close()\n        }\n    })\n}\n"
  },
  {
    "path": "backend/src/common-event-handler.ts",
    "content": "import * as Y from \"yjs\"\nimport { AppEvent, Board, CrdtEnabled, checkBoardAccess, defaultBoardSize } from \"../../common/src/domain\"\nimport { addBoard } from \"./board-state\"\nimport { fetchBoard } from \"./board-store\"\nimport { yWebSocketServer } from \"./board-yjs-server\"\nimport { MessageHandlerResult } from \"./connection-handler\"\nimport { getAuthenticatedUserFromJWT } from \"./http-session\"\nimport {\n    associateUserWithBoard,\n    dissociateUserWithBoard,\n    getUserAssociatedBoards,\n    getUserIdForEmail,\n} from \"./user-store\"\nimport { getSession, logoutUser, setNicknameForSession, setVerifiedUserForSession } from \"./websocket-sessions\"\nimport { WsWrapper, toBuffer } from \"./ws-wrapper\"\n\nexport async function handleCommonEvent(socket: WsWrapper, appEvent: AppEvent): Promise<MessageHandlerResult> {\n    switch (appEvent.action) {\n        case \"auth.login.jwt\": {\n            const user = getAuthenticatedUserFromJWT(appEvent.jwt)\n            const session = getSession(socket)\n            if (session && user !== null) {\n                const userId = await getUserIdForEmail(user.email)\n                const userInfo = await setVerifiedUserForSession(user, session)\n                console.log(`${user.name} logged in`)\n                session.sendEvent({ action: \"auth.login.response\", success: true, userId })\n                if (session.boardSession) {\n                    await associateUserWithBoard(userId, session.boardSession.boardId)\n                }\n                session.sendEvent({\n                    action: \"user.boards\",\n                    email: user.email,\n                    boards: await getUserAssociatedBoards(userInfo),\n                })\n            } else if (session) {\n                session.sendEvent({ action: \"auth.login.response\", success: false })\n            }\n            return true\n        }\n        case \"auth.logout\": {\n            const session = getSession(socket)\n            if (session && session.userInfo.userType === \"authenticated\") {\n                logoutUser(appEvent, socket)\n                console.log(`${session.userInfo.name} logged out`)\n            }\n            socket.close()\n            return true\n        }\n        case \"nickname.set\": {\n            setNicknameForSession(appEvent, socket)\n            return true\n        }\n        case \"board.associate\": {\n            // TODO: maybe access check? Not security-wise necessary\n            const session = getSession(socket)\n            if (session) {\n                if (session.userInfo.userType !== \"authenticated\") {\n                    console.warn(\"Trying to associate board without authenticated user\")\n                    return true\n                }\n                const userId = session.userInfo.userId\n                await associateUserWithBoard(userId, appEvent.boardId, appEvent.lastOpened)\n            }\n            return true\n        }\n        case \"board.dissociate\": {\n            const session = getSession(socket)\n            if (session) {\n                if (session.userInfo.userType !== \"authenticated\") {\n                    console.warn(\"Trying to dissociate board without authenticated user\")\n                    return true\n                }\n                const userId = session.userInfo.userId\n                await dissociateUserWithBoard(userId, appEvent.boardId)\n            }\n            return true\n        }\n        case \"board.add\": {\n            const session = getSession(socket)\n            if (session) {\n                const { payload } = appEvent\n                let template: Board | null = null\n                if (\"templateId\" in payload && payload.templateId) {\n                    const aliased = process.env[`BOARD_ALIAS_${payload.templateId}`]\n                    const templateId = aliased || payload.templateId\n                    const found = await fetchBoard(templateId)\n                    if (found) {\n                        const accessLevel = checkBoardAccess(found.board.accessPolicy, session.userInfo)\n                        if (accessLevel === \"none\") {\n                            console.warn(`Trying to use board ${found.board.id} as template, without board permissions`)\n                            return true\n                        }\n                        template = { ...found.board, accessPolicy: undefined }\n                    } else {\n                        console.error(`Template ${payload.templateId}${aliased ? `(${templateId})` : \"\"} not found`)\n                    }\n                }\n                const board = { ...defaultBoardSize, items: {}, connections: [], ...template, ...payload, serial: 0 }\n                if (template && template.crdt === CrdtEnabled) {\n                    const templateDoc = await yWebSocketServer.docs.getYDocAndWaitForFetch(template.id)\n                    const newDoc = await yWebSocketServer.docs.getYDocAndWaitForFetch(board.id)\n                    Y.applyUpdate(newDoc, Y.encodeStateAsUpdate(templateDoc))\n                }\n                await addBoard(board)\n                socket.send(toBuffer({ action: \"board.add.ack\", boardId: board.id }))\n            }\n            return true\n        }\n        case \"ping\": {\n            return true\n        }\n    }\n    return false\n}\n"
  },
  {
    "path": "backend/src/compact-history.ts",
    "content": "import { format } from \"date-fns\"\nimport _ from \"lodash\"\nimport { BoardHistoryEntry, Id } from \"../../common/src/domain\"\nimport {\n    BoardHistoryBundleMeta,\n    getBoardHistoryBundleMetas,\n    getBoardHistoryBundlesWithLastSerialsBetween,\n    storeEventHistoryBundle,\n    verifyContinuity,\n    verifyContinuityFromMetas,\n    verifyEventArrayContinuity,\n} from \"./board-store\"\nimport * as Y from \"yjs\"\nimport { inTransaction } from \"./db\"\n\nfunction chunkBy<T>(arr: T[], shouldSplit: (a: T, b: T) => boolean) {\n    const result = []\n    let currentChunk = []\n    for (let i = 0; i < arr.length; i++) {\n        if (i > 0 && shouldSplit(arr[i - 1], arr[i])) {\n            result.push(currentChunk)\n            currentChunk = []\n        }\n        currentChunk.push(arr[i])\n    }\n    result.push(currentChunk)\n    return result\n}\nfunction getHour(b: BoardHistoryBundleMeta) {\n    return format(new Date(b.saved_at), \"yyyy-MM-dd hh\")\n}\n\nexport async function quickCompactBoardHistory(id: Id): Promise<number> {\n    try {\n        return await inTransaction(async (client) => {\n            // Lock the board to prevent loading the board while compacting\n            await client.query(\"select 1 from board where id=$1 for update\", [id])\n            const bundleMetas = await getBoardHistoryBundleMetas(client, id)\n            if (bundleMetas.length === 0) return 0\n            const consistent = verifyContinuityFromMetas(id, 0, bundleMetas)\n            if (consistent) {\n                // Group in one-hour bundles\n                //console.log(\"Grouped by date\", groupedByHour)\n                const toCompact = chunkBy(\n                    bundleMetas,\n                    (a, b) => getHour(a) !== getHour(b) && a.last_serial !== b.last_serial,\n                ).filter((chunk) => chunk.length > 1)\n                let compactions = 0\n                for (let bs of toCompact) {\n                    const firstBundle = bs[0]\n                    const lastBundle = bs[bs.length - 1]\n                    console.log(\n                        `Compacting ${bs.length} bundles into one for board ${id}, containing serials ${firstBundle.first_serial}...${lastBundle.last_serial}`,\n                    )\n                    const lastSerial = lastBundle.last_serial\n                    const bundlesWithData = await getBoardHistoryBundlesWithLastSerialsBetween(\n                        client,\n                        id,\n                        firstBundle.last_serial,\n                        lastSerial,\n                    )\n                    const eventArrays = bundlesWithData.map((b) => b.events.events)\n                    const events: BoardHistoryEntry[] = eventArrays.flat()\n                    const crdtUpdates = bundlesWithData.flatMap((d) => (d.crdt_update ? [d.crdt_update] : []))\n                    const combinedCrdtUpdate = crdtUpdates.length ? Y.mergeUpdates(crdtUpdates) : null\n                    const initSerial = firstBundle.first_serial\n                        ? firstBundle.first_serial - 1\n                        : firstBundle.last_serial - 1\n                    const consistent =\n                        verifyContinuity(id, initSerial, ...eventArrays) &&\n                        verifyEventArrayContinuity(id, initSerial, events)\n                    if (consistent && bundlesWithData.length == bs.length) {\n                        // 1. delete existing bundles\n                        const deleteResult = await client.query(\n                            `DELETE FROM board_event where board_id=$1 and last_serial in (${bundlesWithData\n                                .map((b) => b.last_serial)\n                                .join(\",\")})`,\n                            [id],\n                        )\n                        if (deleteResult.rowCount != bs.length) {\n                            throw Error(\n                                `Unexpected rowcount when deleting on compaction: ${deleteResult.rowCount} for board ${id}`,\n                            )\n                        }\n                        // 2. store as a single bundle\n                        await storeEventHistoryBundle(\n                            id,\n                            events,\n                            lastSerial,\n                            combinedCrdtUpdate,\n                            client,\n                            lastBundle.saved_at,\n                        )\n                    } else {\n                        throw Error(\"Discontinuity detected in compacted history.\")\n                    }\n                    compactions++\n                }\n                if (compactions > 0) {\n                } else {\n                    console.log(\n                        `Board ${id}: Verified ${bundleMetas.length} bundles containing ${\n                            bundleMetas[bundleMetas.length - 1].last_serial\n                        } events => no need to compact`,\n                    )\n                }\n                return compactions\n            } else {\n                throw Error(\"Discontinuity detected in bundle metadata.\")\n            }\n        })\n    } catch (e) {\n        console.error(`Aborting compaction of board ${id} because of an error: ${e}`)\n        return 0\n    }\n}\n"
  },
  {
    "path": "backend/src/config.ts",
    "content": "import path from \"path\"\nimport fs from \"fs\"\nimport { authProvider } from \"./oauth\"\nimport * as t from \"io-ts\"\nimport { optional } from \"../../common/src/domain\"\nimport { decodeOrThrow } from \"./decodeOrThrow\"\n\nexport type StorageBackend = Readonly<\n    { type: \"LOCAL\"; directory: string; assetStorageURL: string } | { type: \"AWS\"; assetStorageURL: string }\n>\nexport type Config = Readonly<{ storageBackend: StorageBackend; authSupported: boolean; crdt: CrdtConfigString }>\n\nconst CrdtConfigString = t.union([\n    t.literal(\"opt-in\"),\n    t.literal(\"opt-in-authenticated\"),\n    t.literal(\"true\"),\n    t.literal(\"false\"),\n])\nexport type CrdtConfigString = t.TypeOf<typeof CrdtConfigString>\n\nexport const getConfig = (): Config => {\n    const storageBackend: StorageBackend = process.env.AWS_ASSETS_BUCKET_URL\n        ? { type: \"AWS\", assetStorageURL: process.env.AWS_ASSETS_BUCKET_URL }\n        : { type: \"LOCAL\", directory: path.resolve(\"localfiles\"), assetStorageURL: \"/assets\" }\n\n    if (storageBackend.type === \"LOCAL\") {\n        try {\n            fs.mkdirSync(storageBackend.directory)\n        } catch (e) {}\n    }\n\n    const crdt = decodeOrThrow(CrdtConfigString, process.env.COLLABORATIVE_EDITING ?? \"opt-in\")\n\n    return {\n        storageBackend,\n        authSupported: authProvider !== null,\n        crdt,\n    }\n}\n"
  },
  {
    "path": "backend/src/connection-handler.ts",
    "content": "import { AppEvent, Id, Serial, EventWrapper } from \"../../common/src/domain\"\nimport { getActiveBoards } from \"./board-state\"\nimport { getConfig } from \"./config\"\nimport { releaseLocksFor } from \"./locker\"\nimport { broadcastCursorPositions, endSession, startSession } from \"./websocket-sessions\"\nimport { WsWrapper, toBuffer } from \"./ws-wrapper\"\n\nexport type ConnectionHandlerParams = Readonly<{\n    getSignedPutUrl: (key: string) => string\n}>\n\nexport const connectionHandler = (socket: WsWrapper, handleMessage: MessageHandler) => {\n    startSession(socket)\n    const config = getConfig()\n    socket.send(\n        toBuffer({\n            action: \"server.config\",\n            assetStorageURL: config.storageBackend.assetStorageURL,\n            authSupported: config.authSupported,\n            crdt: config.crdt,\n        }),\n    )\n    socket.onError(() => {\n        socket.close()\n    })\n    socket.onMessage(async (o: object) => {\n        try {\n            let event = o as EventWrapper\n            let serialsToAck: Record<Id, Serial> = {}\n            for (const e of event.events) {\n                const serialAck = await handleMessage(socket, e)\n                if (serialAck === true) {\n                } else if (serialAck === false) {\n                    console.warn(\"Unhandled app-event message\", e)\n                } else {\n                    serialsToAck[serialAck.boardId] = serialAck.serial\n                }\n            }\n            if (event.ackId) {\n                socket.send(toBuffer({ action: \"ack\", ackId: event.ackId, serials: serialsToAck }))\n            }\n        } catch (e) {\n            console.error(\"Error while handling event from client. Closing connection.\", e)\n            socket.close()\n        }\n    })\n\n    socket.onClose(() => {\n        endSession(socket)\n        getActiveBoards().forEach((state) => {\n            delete state.cursorPositions[socket.id]\n            state.cursorsMoved = true\n        })\n        releaseLocksFor(socket)\n    })\n}\n\nsetInterval(() => {\n    getActiveBoards().forEach((bh) => {\n        if (bh.cursorsMoved) {\n            broadcastCursorPositions(bh.board.id, bh.cursorPositions)\n            bh.cursorsMoved = false\n        }\n    })\n}, 100)\n\nexport type MessageHandler = (socket: WsWrapper, appEvent: AppEvent) => Promise<MessageHandlerResult>\nexport type MessageHandlerResult = { boardId: Id; serial: Serial } | boolean\n"
  },
  {
    "path": "backend/src/db.ts",
    "content": "import pg, { PoolClient } from \"pg\"\nimport process from \"process\"\nimport migrate from \"node-pg-migrate\"\n\nconst DATABASE_URL = process.env.DATABASE_URL ?? \"postgres://r-board:secret@127.0.0.1:13338/r-board\"\nconst DATABASE_SSL_ENABLED = process.env.DATABASE_SSL_ENABLED === \"true\"\n\nconst pgConfig = {\n    connectionString: DATABASE_URL,\n    ssl: DATABASE_SSL_ENABLED\n        ? {\n              rejectUnauthorized: false,\n          }\n        : undefined,\n}\nconst connectionPool = new pg.Pool(pgConfig)\n\nexport function closeConnectionPool() {\n    connectionPool.end()\n}\n\nexport async function initDB(backendDir: string = \".\") {\n    console.log(\"Running database migrations\")\n    await inTransaction((client) =>\n        migrate({\n            count: 100000,\n            databaseUrl: DATABASE_URL,\n            migrationsTable: \"pgmigrations\",\n            dir: `${backendDir}/migrations`,\n            direction: \"up\",\n            dbClient: client,\n        }),\n    )\n    console.log(\"Completed database migrations\")\n\n    return {\n        onEvent: async (eventNames: string[], cb: (n: pg.Notification) => any) => {\n            const client = await connectionPool.connect()\n            eventNames.map((e) => client.query(`LISTEN ${e}`))\n            client.on(\"notification\", cb)\n        },\n    }\n}\n\nexport async function withDBClient<T>(f: (client: PoolClient) => Promise<T>): Promise<T> {\n    const client = await connectionPool.connect()\n    try {\n        await client.query(\"BEGIN;SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;\")\n        return await f(client)\n    } finally {\n        await client.query(\"ROLLBACK;\")\n        client.release()\n    }\n}\n\nexport async function inTransaction<T>(f: (client: PoolClient) => Promise<T>): Promise<T> {\n    const client = await connectionPool.connect()\n    try {\n        await client.query(`\n            BEGIN;\n            SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE;\n        `)\n        const result = await f(client)\n        await client.query(\"COMMIT;\")\n        return result\n    } catch (e) {\n        await client.query(\"ROLLBACK;\")\n        throw e\n    } finally {\n        client.release()\n    }\n}\n"
  },
  {
    "path": "backend/src/decodeOrThrow.ts",
    "content": "import * as t from \"io-ts\"\nimport { Left, isLeft, left } from \"fp-ts/lib/Either\"\nimport { PathReporter } from \"io-ts/lib/PathReporter\"\n\nexport function decodeOrThrow<T>(codec: t.Type<T, any>, input: any): T {\n    const validationResult = codec.decode(input)\n    if (isLeft(validationResult)) {\n        throw new ValidationError(validationResult)\n    }\n    return validationResult.right\n}\n\nclass ValidationError extends Error {\n    constructor(errors: Left<t.Errors>) {\n        super(report_(errors.left))\n    }\n}\n\nfunction report_(errors: t.Errors) {\n    return PathReporter.report(left(errors)).join(\"\\n\")\n}\n"
  },
  {
    "path": "backend/src/env.ts",
    "content": "import process from \"process\"\n\nexport function getEnv(name: string): string {\n    const value = process.env[name]\n    if (!value) throw new Error(\"Missing ENV: \" + name)\n    return value\n}\n"
  },
  {
    "path": "backend/src/expiring-map.ts",
    "content": "export function AutoExpiringMap<V extends any>(ttlSeconds: number) {\n    const timers = new Map<string | number, NodeJS.Timeout | undefined>()\n    const data: Record<string, V> = {}\n    const listeners: ((v: Record<string, V>) => any)[] = []\n    const proxy = new Proxy(data, {\n        set(target, key, value) {\n            if (typeof key === \"symbol\") return false\n            target[key] = value\n            setExpiryTimer(key)\n            listeners.forEach((l) => l(target))\n            return true\n        },\n        deleteProperty(target, key) {\n            if (typeof key === \"symbol\") return false\n            const didDelete = delete target[key]\n            if (!didDelete) {\n                return false\n            }\n            listeners.forEach((l) => l(target))\n            return true\n        },\n    })\n\n    const setExpiryTimer = (key: string | number) => {\n        if (timers.has(key)) {\n            clearTimeout(timers.get(key)!)\n        }\n\n        timers.set(\n            key,\n            setTimeout(() => {\n                timers.delete(key)\n                delete proxy[key]\n            }, ttlSeconds * 1000),\n        )\n    }\n\n    const autoExpiringMap = {\n        get: (key: string) => proxy[key],\n        has: (key: string) => !!proxy[key],\n        entries: () => Object.entries(proxy),\n        delete: (key: string) => delete proxy[key],\n        set: (key: string, value: any) => {\n            proxy[key] = value\n        },\n        onChange: (fn: (v: Record<string, V>) => any) => {\n            listeners.push(fn)\n            return autoExpiringMap\n        },\n    }\n\n    return autoExpiringMap\n}\n"
  },
  {
    "path": "backend/src/express-server.ts",
    "content": "import dotenv from \"dotenv\"\nimport express from \"express\"\nimport expressWs from \"express-ws\"\nimport fs from \"fs\"\nimport * as Http from \"http\"\nimport * as Https from \"https\"\nimport * as path from \"path\"\n\nimport apiRoutes from \"./api/api-routes\"\nimport { handleBoardEvent } from \"./board-event-handler\"\nimport { BoardYJSServer } from \"./board-yjs-server\"\nimport { getConfig } from \"./config\"\nimport { connectionHandler } from \"./connection-handler\"\nimport { getEnv } from \"./env\"\nimport { authProvider, setupAuth } from \"./oauth\"\n\nimport { possiblyRequireAuth } from \"./require-auth\"\nimport { createGetSignedPutUrl } from \"./storage\"\nimport { WsWrapper } from \"./ws-wrapper\"\nimport Cookies from \"cookies\"\nimport { removeAuthenticatedUser, setAuthenticatedUser } from \"./http-session\"\n\ndotenv.config()\n\nexport const startExpressServer = (httpPort?: number, httpsPort?: number): (() => void) => {\n    const config = getConfig()\n\n    const app = express()\n\n    if (authProvider) {\n        setupAuth(app, authProvider)\n    } else {\n        app.get(\"/logout\", async (req, res) => {\n            removeAuthenticatedUser(req, res)\n            res.redirect(\"/\")\n        })\n    }\n    app.get(\"/test-callback\", async (req, res) => {\n        const cookies = new Cookies(req, res)\n        const returnTo = cookies.get(\"returnTo\") || \"/\"\n        setAuthenticatedUser(req, res, { domain: null, email: \"ourboardtester@test.com\", name: \"Ourboard tester\" })\n        res.redirect(returnTo)\n    })\n\n    possiblyRequireAuth(app)\n\n    app.use(\"/\", express.static(\"../frontend/dist\"))\n    app.use(\"/\", express.static(\"../frontend/public\"))\n\n    if (config.storageBackend.type === \"LOCAL\") {\n        const localDirectory = config.storageBackend.directory\n        app.put(\"/assets/:id\", (req, res) => {\n            if (!req.params.id) {\n                return res.sendStatus(400)\n            }\n\n            const w = fs.createWriteStream(localDirectory + \"/\" + req.params.id)\n\n            req.pipe(w)\n\n            req.on(\"end\", () => {\n                !res.headersSent && res.sendStatus(200)\n            })\n\n            w.on(\"error\", () => {\n                res.sendStatus(500)\n            })\n        })\n        app.use(\"/assets\", express.static(localDirectory))\n    }\n\n    app.get(\"/assets/external\", (req, res) => {\n        const src = req.query.src\n        if (typeof src !== \"string\" || [\"http://\", \"https://\"].every((prefix) => !src.startsWith(prefix)))\n            return res.send(400)\n        const protocol = src.startsWith(\"https://\") ? Https : Http\n\n        protocol\n            .request(src, (upstreamResponse) => {\n                res.writeHead(upstreamResponse.statusCode!, upstreamResponse.headers)\n                upstreamResponse\n                    .pipe(res, {\n                        end: true,\n                    })\n                    .on(\"error\", (err) => res.status(500).send(err.message))\n            })\n            .end()\n    })\n\n    app.get(\"/b/:boardId\", async (req, res) => {\n        res.sendFile(path.resolve(\"../frontend/dist/index.html\"))\n    })\n\n    app.use(apiRoutes.handler())\n\n    let stop = () => {}\n\n    if (httpPort) {\n        const http = new Http.Server(app)\n        startWs(http, app)\n        http.listen(httpPort, () => {\n            console.log(\"Listening HTTP on port \" + httpPort)\n        })\n        const prevStop = stop\n        stop = () => {\n            prevStop()\n            http.close()\n        }\n    }\n\n    if (httpsPort) {\n        let https = new Https.Server(\n            {\n                cert: fs.readFileSync(getEnv(\"HTTPS_CERT_FILE\")),\n                key: fs.readFileSync(getEnv(\"HTTPS_KEY_FILE\")),\n            },\n            app,\n        )\n        startWs(https, app)\n        https.listen(httpsPort, () => {\n            console.log(\"Listening HTTPS on port \" + httpsPort)\n        })\n        const prevStop = stop\n        stop = () => {\n            prevStop()\n            https.close()\n        }\n    }\n\n    const redirectURL = process.env.REDIRECT_URL\n    if (redirectURL) {\n        app.get(\"*\", function (req, res, next) {\n            if (req.headers[\"x-forwarded-proto\"] !== \"https\") {\n                res.redirect(redirectURL)\n            } else {\n                next()\n            }\n        })\n    }\n    return stop\n}\n\nfunction startWs(http: any, app: express.Express) {\n    const ws: expressWs.Instance = expressWs(app, http)\n\n    const signedPutUrl = createGetSignedPutUrl(getConfig().storageBackend)\n\n    ws.app.ws(\"/socket/lobby\", (socket, req) => {\n        connectionHandler(WsWrapper(socket), handleBoardEvent(null, signedPutUrl))\n    })\n    ws.app.ws(\"/socket/board/:boardId\", (socket, req) => {\n        const boardId = req.params.boardId\n        connectionHandler(WsWrapper(socket), handleBoardEvent(boardId, signedPutUrl))\n    })\n\n    BoardYJSServer(ws, \"/socket/yjs/board/:boardId/\")\n\n    ws.app.ws(\"*\", (socket, req) => {\n        console.warn(`Unexpected WS connection: ${req.url} `)\n        socket.close()\n    })\n}\n"
  },
  {
    "path": "backend/src/generic-oidc-auth.ts",
    "content": "import { Request, Response } from \"express\"\nimport * as t from \"io-ts\"\nimport JWT from \"jsonwebtoken\"\nimport { OAuthAuthenticatedUser } from \"../../common/src/authenticated-user\"\nimport { optional } from \"../../common/src/domain\"\nimport { decodeOrThrow } from \"./decodeOrThrow\"\nimport { getEnv } from \"./env\"\nimport { ROOT_URL } from \"./host-config\"\nimport { AuthProvider } from \"./oauth\"\nimport { REQUIRE_AUTH } from \"./require-auth\"\n\ntype GenericOAuthConfig = {\n    OIDC_CONFIG_URL: string\n    OIDC_CLIENT_ID: string\n    OIDC_CLIENT_SECRET: string\n    OIDC_LOGOUT?: string\n}\n\nexport const genericOIDCConfig: GenericOAuthConfig | null = process.env.OIDC_CONFIG_URL\n    ? {\n          OIDC_CONFIG_URL: getEnv(\"OIDC_CONFIG_URL\"),\n          OIDC_CLIENT_ID: getEnv(\"OIDC_CLIENT_ID\"),\n          OIDC_CLIENT_SECRET: getEnv(\"OIDC_CLIENT_SECRET\"),\n          OIDC_LOGOUT: process.env.OIDC_LOGOUT,\n      }\n    : null\n\nexport function GenericOIDCAuthProvider(config: GenericOAuthConfig): AuthProvider {\n    console.log(`Setting up generic OAuth authentication using client id ${config.OIDC_CLIENT_ID}`)\n\n    const callbackUrl = `${ROOT_URL}/google-callback`\n\n    const openIdConfiguration = (async () => {\n        const response = await fetch(config.OIDC_CONFIG_URL)\n        return decodeOrThrow(OpenIdConfiguration, await response.json())\n    })()\n\n    async function getAccountFromCode(code: string): Promise<OAuthAuthenticatedUser> {\n        const response = await fetch((await openIdConfiguration).token_endpoint, {\n            method: \"POST\",\n            headers: {\n                \"content-type\": \"application/x-www-form-urlencoded\",\n            },\n            body: `grant_type=authorization_code&code=${encodeURIComponent(code)}&client_id=${encodeURIComponent(\n                config.OIDC_CLIENT_ID,\n            )}&client_secret=${config.OIDC_CLIENT_SECRET}&redirect_uri=${callbackUrl}`,\n        })\n\n        const body = await response.json()\n\n        const idToken = JWT.decode(body.id_token)\n        //console.log(JSON.stringify(idToken, null, 2))\n        const user = decodeOrThrow(IdToken, idToken)\n        return {\n            email: user.email,\n            name: \"name\" in user ? user.name : user.preferred_username,\n            picture: user.picture ?? undefined,\n            domain: user.hd ?? null,\n        }\n    }\n\n    async function getAuthPageURL() {\n        const scopes = \"email openid profile\"\n        const state = \"TODO\"\n        const redirectUri = callbackUrl\n        return `${(await openIdConfiguration).authorization_endpoint}?scope=${encodeURIComponent(\n            scopes,\n        )}&response_type=code&state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent(\n            redirectUri,\n        )}&client_id=${config.OIDC_CLIENT_ID}`\n    }\n\n    const shouldHandleLogout = REQUIRE_AUTH || config.OIDC_LOGOUT\n\n    async function getLogoutUrl(): Promise<string> {\n        if (config.OIDC_LOGOUT && config.OIDC_LOGOUT !== \"true\") {\n            return config.OIDC_LOGOUT\n        }\n\n        const logoutUrl = (await openIdConfiguration).end_session_endpoint\n\n        if (!logoutUrl) {\n            throw Error(\n                `OIDC configuration at ${config.OIDC_CONFIG_URL} does not specify end_session_endpoint. Use OIDC_LOGOUT environment variable to define the logout endpoint explicitly.`,\n            )\n        }\n\n        return logoutUrl\n    }\n\n    const logout = shouldHandleLogout\n        ? async (req: Request, res: Response) => {\n              res.redirect(await getLogoutUrl())\n          }\n        : undefined\n\n    return {\n        getAccountFromCode,\n        getAuthPageURL,\n        logout,\n    }\n}\n\nconst OpenIdConfiguration = t.type({\n    authorization_endpoint: t.string,\n    token_endpoint: t.string,\n    end_session_endpoint: optional(t.string),\n})\n\nconst IdToken = t.union([\n    t.type({\n        email: t.string,\n        name: t.string,\n        picture: optional(t.string),\n        hd: optional(t.string),\n    }),\n    t.type({\n        email: t.string,\n        preferred_username: t.string,\n        picture: optional(t.string),\n        hd: optional(t.string),\n    }),\n])\n"
  },
  {
    "path": "backend/src/github-webhook/example-payload.json",
    "content": "{\n    \"action\": \"assigned\",\n    \"issue\": {\n        \"url\": \"https://api.github.com/repos/raimohanska/r-board/issues/129\",\n        \"repository_url\": \"https://api.github.com/repos/raimohanska/r-board\",\n        \"labels_url\": \"https://api.github.com/repos/raimohanska/r-board/issues/129/labels{/name}\",\n        \"comments_url\": \"https://api.github.com/repos/raimohanska/r-board/issues/129/comments\",\n        \"events_url\": \"https://api.github.com/repos/raimohanska/r-board/issues/129/events\",\n        \"html_url\": \"https://github.com/raimohanska/r-board/issues/129\",\n        \"id\": 810436967,\n        \"node_id\": \"MDU6SXNzdWU4MTA0MzY5Njc=\",\n        \"number\": 129,\n        \"title\": \"Github issues integration\",\n        \"user\": {\n            \"login\": \"raimohanska\",\n            \"id\": 292964,\n            \"node_id\": \"MDQ6VXNlcjI5Mjk2NA==\",\n            \"avatar_url\": \"https://avatars.githubusercontent.com/u/292964?v=4\",\n            \"gravatar_id\": \"\",\n            \"url\": \"https://api.github.com/users/raimohanska\",\n            \"html_url\": \"https://github.com/raimohanska\",\n            \"followers_url\": \"https://api.github.com/users/raimohanska/followers\",\n            \"following_url\": \"https://api.github.com/users/raimohanska/following{/other_user}\",\n            \"gists_url\": \"https://api.github.com/users/raimohanska/gists{/gist_id}\",\n            \"starred_url\": \"https://api.github.com/users/raimohanska/starred{/owner}{/repo}\",\n            \"subscriptions_url\": \"https://api.github.com/users/raimohanska/subscriptions\",\n            \"organizations_url\": \"https://api.github.com/users/raimohanska/orgs\",\n            \"repos_url\": \"https://api.github.com/users/raimohanska/repos\",\n            \"events_url\": \"https://api.github.com/users/raimohanska/events{/privacy}\",\n            \"received_events_url\": \"https://api.github.com/users/raimohanska/received_events\",\n            \"type\": \"User\",\n            \"site_admin\": false\n        },\n        \"labels\": [\n            {\n                \"id\": 2438308116,\n                \"node_id\": \"MDU6TGFiZWwyNDM4MzA4MTE2\",\n                \"url\": \"https://api.github.com/repos/raimohanska/r-board/labels/enhancement\",\n                \"name\": \"bug\",\n                \"color\": \"a2eeef\",\n                \"default\": true,\n                \"description\": \"Bug\"\n            }\n        ],\n        \"state\": \"open\",\n        \"locked\": false,\n        \"assignee\": {\n            \"login\": \"raimohanska\",\n            \"id\": 292964,\n            \"node_id\": \"MDQ6VXNlcjI5Mjk2NA==\",\n            \"avatar_url\": \"https://avatars.githubusercontent.com/u/292964?v=4\",\n            \"gravatar_id\": \"\",\n            \"url\": \"https://api.github.com/users/raimohanska\",\n            \"html_url\": \"https://github.com/raimohanska\",\n            \"followers_url\": \"https://api.github.com/users/raimohanska/followers\",\n            \"following_url\": \"https://api.github.com/users/raimohanska/following{/other_user}\",\n            \"gists_url\": \"https://api.github.com/users/raimohanska/gists{/gist_id}\",\n            \"starred_url\": \"https://api.github.com/users/raimohanska/starred{/owner}{/repo}\",\n            \"subscriptions_url\": \"https://api.github.com/users/raimohanska/subscriptions\",\n            \"organizations_url\": \"https://api.github.com/users/raimohanska/orgs\",\n            \"repos_url\": \"https://api.github.com/users/raimohanska/repos\",\n            \"events_url\": \"https://api.github.com/users/raimohanska/events{/privacy}\",\n            \"received_events_url\": \"https://api.github.com/users/raimohanska/received_events\",\n            \"type\": \"User\",\n            \"site_admin\": false\n        },\n        \"assignees\": [\n            {\n                \"login\": \"raimohanska\",\n                \"id\": 292964,\n                \"node_id\": \"MDQ6VXNlcjI5Mjk2NA==\",\n                \"avatar_url\": \"https://avatars.githubusercontent.com/u/292964?v=4\",\n                \"gravatar_id\": \"\",\n                \"url\": \"https://api.github.com/users/raimohanska\",\n                \"html_url\": \"https://github.com/raimohanska\",\n                \"followers_url\": \"https://api.github.com/users/raimohanska/followers\",\n                \"following_url\": \"https://api.github.com/users/raimohanska/following{/other_user}\",\n                \"gists_url\": \"https://api.github.com/users/raimohanska/gists{/gist_id}\",\n                \"starred_url\": \"https://api.github.com/users/raimohanska/starred{/owner}{/repo}\",\n                \"subscriptions_url\": \"https://api.github.com/users/raimohanska/subscriptions\",\n                \"organizations_url\": \"https://api.github.com/users/raimohanska/orgs\",\n                \"repos_url\": \"https://api.github.com/users/raimohanska/repos\",\n                \"events_url\": \"https://api.github.com/users/raimohanska/events{/privacy}\",\n                \"received_events_url\": \"https://api.github.com/users/raimohanska/received_events\",\n                \"type\": \"User\",\n                \"site_admin\": false\n            }\n        ],\n        \"milestone\": null,\n        \"comments\": 0,\n        \"created_at\": \"2021-02-17T18:39:11Z\",\n        \"updated_at\": \"2021-02-17T19:08:21Z\",\n        \"closed_at\": null,\n        \"author_association\": \"OWNER\",\n        \"active_lock_reason\": null,\n        \"body\": \"\",\n        \"performed_via_github_app\": null\n    },\n    \"assignee\": {\n        \"login\": \"raimohanska\",\n        \"id\": 292964,\n        \"node_id\": \"MDQ6VXNlcjI5Mjk2NA==\",\n        \"avatar_url\": \"https://avatars.githubusercontent.com/u/292964?v=4\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/raimohanska\",\n        \"html_url\": \"https://github.com/raimohanska\",\n        \"followers_url\": \"https://api.github.com/users/raimohanska/followers\",\n        \"following_url\": \"https://api.github.com/users/raimohanska/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/raimohanska/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/raimohanska/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/raimohanska/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/raimohanska/orgs\",\n        \"repos_url\": \"https://api.github.com/users/raimohanska/repos\",\n        \"events_url\": \"https://api.github.com/users/raimohanska/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/raimohanska/received_events\",\n        \"type\": \"User\",\n        \"site_admin\": false\n    },\n    \"repository\": {\n        \"id\": 305431036,\n        \"node_id\": \"MDEwOlJlcG9zaXRvcnkzMDU0MzEwMzY=\",\n        \"name\": \"r-board\",\n        \"full_name\": \"raimohanska/r-board\",\n        \"private\": false,\n        \"owner\": {\n            \"login\": \"raimohanska\",\n            \"id\": 292964,\n            \"node_id\": \"MDQ6VXNlcjI5Mjk2NA==\",\n            \"avatar_url\": \"https://avatars.githubusercontent.com/u/292964?v=4\",\n            \"gravatar_id\": \"\",\n            \"url\": \"https://api.github.com/users/raimohanska\",\n            \"html_url\": \"https://github.com/raimohanska\",\n            \"followers_url\": \"https://api.github.com/users/raimohanska/followers\",\n            \"following_url\": \"https://api.github.com/users/raimohanska/following{/other_user}\",\n            \"gists_url\": \"https://api.github.com/users/raimohanska/gists{/gist_id}\",\n            \"starred_url\": \"https://api.github.com/users/raimohanska/starred{/owner}{/repo}\",\n            \"subscriptions_url\": \"https://api.github.com/users/raimohanska/subscriptions\",\n            \"organizations_url\": \"https://api.github.com/users/raimohanska/orgs\",\n            \"repos_url\": \"https://api.github.com/users/raimohanska/repos\",\n            \"events_url\": \"https://api.github.com/users/raimohanska/events{/privacy}\",\n            \"received_events_url\": \"https://api.github.com/users/raimohanska/received_events\",\n            \"type\": \"User\",\n            \"site_admin\": false\n        },\n        \"html_url\": \"https://github.com/raimohanska/r-board\",\n        \"description\": \"An online whiteboard\",\n        \"fork\": false,\n        \"url\": \"https://api.github.com/repos/raimohanska/r-board\",\n        \"forks_url\": \"https://api.github.com/repos/raimohanska/r-board/forks\",\n        \"keys_url\": \"https://api.github.com/repos/raimohanska/r-board/keys{/key_id}\",\n        \"collaborators_url\": \"https://api.github.com/repos/raimohanska/r-board/collaborators{/collaborator}\",\n        \"teams_url\": \"https://api.github.com/repos/raimohanska/r-board/teams\",\n        \"hooks_url\": \"https://api.github.com/repos/raimohanska/r-board/hooks\",\n        \"issue_events_url\": \"https://api.github.com/repos/raimohanska/r-board/issues/events{/number}\",\n        \"events_url\": \"https://api.github.com/repos/raimohanska/r-board/events\",\n        \"assignees_url\": \"https://api.github.com/repos/raimohanska/r-board/assignees{/user}\",\n        \"branches_url\": \"https://api.github.com/repos/raimohanska/r-board/branches{/branch}\",\n        \"tags_url\": \"https://api.github.com/repos/raimohanska/r-board/tags\",\n        \"blobs_url\": \"https://api.github.com/repos/raimohanska/r-board/git/blobs{/sha}\",\n        \"git_tags_url\": \"https://api.github.com/repos/raimohanska/r-board/git/tags{/sha}\",\n        \"git_refs_url\": \"https://api.github.com/repos/raimohanska/r-board/git/refs{/sha}\",\n        \"trees_url\": \"https://api.github.com/repos/raimohanska/r-board/git/trees{/sha}\",\n        \"statuses_url\": \"https://api.github.com/repos/raimohanska/r-board/statuses/{sha}\",\n        \"languages_url\": \"https://api.github.com/repos/raimohanska/r-board/languages\",\n        \"stargazers_url\": \"https://api.github.com/repos/raimohanska/r-board/stargazers\",\n        \"contributors_url\": \"https://api.github.com/repos/raimohanska/r-board/contributors\",\n        \"subscribers_url\": \"https://api.github.com/repos/raimohanska/r-board/subscribers\",\n        \"subscription_url\": \"https://api.github.com/repos/raimohanska/r-board/subscription\",\n        \"commits_url\": \"https://api.github.com/repos/raimohanska/r-board/commits{/sha}\",\n        \"git_commits_url\": \"https://api.github.com/repos/raimohanska/r-board/git/commits{/sha}\",\n        \"comments_url\": \"https://api.github.com/repos/raimohanska/r-board/comments{/number}\",\n        \"issue_comment_url\": \"https://api.github.com/repos/raimohanska/r-board/issues/comments{/number}\",\n        \"contents_url\": \"https://api.github.com/repos/raimohanska/r-board/contents/{+path}\",\n        \"compare_url\": \"https://api.github.com/repos/raimohanska/r-board/compare/{base}...{head}\",\n        \"merges_url\": \"https://api.github.com/repos/raimohanska/r-board/merges\",\n        \"archive_url\": \"https://api.github.com/repos/raimohanska/r-board/{archive_format}{/ref}\",\n        \"downloads_url\": \"https://api.github.com/repos/raimohanska/r-board/downloads\",\n        \"issues_url\": \"https://api.github.com/repos/raimohanska/r-board/issues{/number}\",\n        \"pulls_url\": \"https://api.github.com/repos/raimohanska/r-board/pulls{/number}\",\n        \"milestones_url\": \"https://api.github.com/repos/raimohanska/r-board/milestones{/number}\",\n        \"notifications_url\": \"https://api.github.com/repos/raimohanska/r-board/notifications{?since,all,participating}\",\n        \"labels_url\": \"https://api.github.com/repos/raimohanska/r-board/labels{/name}\",\n        \"releases_url\": \"https://api.github.com/repos/raimohanska/r-board/releases{/id}\",\n        \"deployments_url\": \"https://api.github.com/repos/raimohanska/r-board/deployments\",\n        \"created_at\": \"2020-10-19T15:33:11Z\",\n        \"updated_at\": \"2021-02-17T18:39:38Z\",\n        \"pushed_at\": \"2021-02-17T18:31:01Z\",\n        \"git_url\": \"git://github.com/raimohanska/r-board.git\",\n        \"ssh_url\": \"git@github.com:raimohanska/r-board.git\",\n        \"clone_url\": \"https://github.com/raimohanska/r-board.git\",\n        \"svn_url\": \"https://github.com/raimohanska/r-board\",\n        \"homepage\": null,\n        \"size\": 1137,\n        \"stargazers_count\": 4,\n        \"watchers_count\": 4,\n        \"language\": \"TypeScript\",\n        \"has_issues\": true,\n        \"has_projects\": true,\n        \"has_downloads\": true,\n        \"has_wiki\": true,\n        \"has_pages\": false,\n        \"forks_count\": 2,\n        \"mirror_url\": null,\n        \"archived\": false,\n        \"disabled\": false,\n        \"open_issues_count\": 33,\n        \"license\": {\n            \"key\": \"other\",\n            \"name\": \"Other\",\n            \"spdx_id\": \"NOASSERTION\",\n            \"url\": null,\n            \"node_id\": \"MDc6TGljZW5zZTA=\"\n        },\n        \"forks\": 2,\n        \"open_issues\": 33,\n        \"watchers\": 4,\n        \"default_branch\": \"master\"\n    },\n    \"sender\": {\n        \"login\": \"raimohanska\",\n        \"id\": 292964,\n        \"node_id\": \"MDQ6VXNlcjI5Mjk2NA==\",\n        \"avatar_url\": \"https://avatars.githubusercontent.com/u/292964?v=4\",\n        \"gravatar_id\": \"\",\n        \"url\": \"https://api.github.com/users/raimohanska\",\n        \"html_url\": \"https://github.com/raimohanska\",\n        \"followers_url\": \"https://api.github.com/users/raimohanska/followers\",\n        \"following_url\": \"https://api.github.com/users/raimohanska/following{/other_user}\",\n        \"gists_url\": \"https://api.github.com/users/raimohanska/gists{/gist_id}\",\n        \"starred_url\": \"https://api.github.com/users/raimohanska/starred{/owner}{/repo}\",\n        \"subscriptions_url\": \"https://api.github.com/users/raimohanska/subscriptions\",\n        \"organizations_url\": \"https://api.github.com/users/raimohanska/orgs\",\n        \"repos_url\": \"https://api.github.com/users/raimohanska/repos\",\n        \"events_url\": \"https://api.github.com/users/raimohanska/events{/privacy}\",\n        \"received_events_url\": \"https://api.github.com/users/raimohanska/received_events\",\n        \"type\": \"User\",\n        \"site_admin\": false\n    }\n}\n"
  },
  {
    "path": "backend/src/google-auth.ts",
    "content": "import { google } from \"googleapis\"\nimport { OAuthAuthenticatedUser } from \"../../common/src/authenticated-user\"\nimport { assertNotNull } from \"../../common/src/assertNotNull\"\nimport { getEnv } from \"./env\"\nimport { AuthProvider } from \"./oauth\"\nimport { ROOT_URL } from \"./host-config\"\nimport { decodeOrThrow } from \"./decodeOrThrow\"\nimport * as T from \"io-ts\"\nimport { optional } from \"../../common/src/domain\"\nimport JWT from \"jsonwebtoken\"\n\ntype GoogleConfig = {\n    clientID: string\n    clientSecret: string\n    callbackURL: string\n}\n\nexport const googleConfig: GoogleConfig | null = process.env.GOOGLE_OAUTH_CLIENT_ID\n    ? {\n          clientID: getEnv(\"GOOGLE_OAUTH_CLIENT_ID\"),\n          clientSecret: getEnv(\"GOOGLE_OAUTH_CLIENT_SECRET\"),\n          callbackURL: `${ROOT_URL}/google-callback`,\n      }\n    : null\n\nexport const GoogleAuthProvider = (googleConfig: GoogleConfig): AuthProvider => {\n    console.log(`Setting up Google authentication using client ID ${googleConfig.clientID}`)\n\n    const googleScopes = [\"email\", \"https://www.googleapis.com/auth/userinfo.profile\"]\n\n    function googleOAUTH2() {\n        if (!googleConfig.clientID || !googleConfig.clientSecret)\n            throw new Error(\"Missing environment variables for Google OAuth\")\n        return new google.auth.OAuth2(googleConfig.clientID, googleConfig.clientSecret, googleConfig.callbackURL)\n    }\n\n    async function getAuthPageURL() {\n        return googleOAUTH2().generateAuthUrl({\n            scope: googleScopes,\n            prompt: \"select_account\",\n        })\n    }\n\n    const IdToken = T.strict({\n        hd: optional(T.string),\n        email: T.string,\n        email_verified: T.boolean,\n        name: T.string,\n        picture: optional(T.string),\n    })\n\n    async function getAccountFromCode(code: string): Promise<OAuthAuthenticatedUser> {\n        const auth = googleOAUTH2()\n        const data = await auth.getToken(code)\n        const idToken = decodeOrThrow(IdToken, JWT.decode(assertNotNull(data.tokens.id_token)))\n        const email = idToken.email\n\n        return {\n            name: idToken.name,\n            email,\n            picture: idToken.picture ?? undefined,\n            domain: idToken.hd ?? null,\n        }\n    }\n\n    return {\n        getAuthPageURL,\n        getAccountFromCode,\n    }\n}\n"
  },
  {
    "path": "backend/src/host-config.ts",
    "content": "export const ROOT_URL = process.env.ROOT_URL ?? \"http://localhost:1337\"\nexport const ROOT_HOST = new URL(ROOT_URL).host\nexport const ROOT_PROTOCOL = new URL(ROOT_URL).protocol\nexport const WS_HOST_LOCAL = (process.env.WS_HOST_LOCAL ?? ROOT_HOST).split(\",\")\nexport const WS_HOST_DEFAULT = process.env.WS_HOST_DEFAULT ?? ROOT_HOST\nexport const WS_PROTOCOL = process.env.WS_PROTOCOL ?? (ROOT_PROTOCOL.startsWith(\"https\") ? \"wss\" : \"ws\")\n"
  },
  {
    "path": "backend/src/http-session.ts",
    "content": "import Cookies from \"cookies\"\nimport { IncomingMessage, ServerResponse } from \"http\"\nimport JWT from \"jsonwebtoken\"\nimport { getEnv } from \"./env\"\nimport { OAuthAuthenticatedUser } from \"../../common/src/authenticated-user\"\nimport { ISOTimeStamp, newISOTimeStamp } from \"../../common/src/domain\"\n\nconst secret = getEnv(\"SESSION_SIGNING_SECRET\")\n\nexport type LoginInfo = OAuthAuthenticatedUser & {\n    timestamp: ISOTimeStamp | undefined\n}\n\nexport function getSessionIdFromCookies(req: IncomingMessage): string | null {\n    return new Cookies(req, null as any).get(\"sessionId\") ?? null\n}\n\n// Get / set authenticated user stored in cookies\nexport function getAuthenticatedUser(req: IncomingMessage): LoginInfo | null {\n    const userCookie = new Cookies(req, null as any).get(\"user\")\n    if (userCookie) {\n        return getAuthenticatedUserFromJWT(userCookie)\n    }\n    return null\n}\n\nexport function getAuthenticatedUserFromJWT(jwt: string): LoginInfo | null {\n    try {\n        JWT.verify(jwt, secret)\n        const loginInfo = JWT.decode(jwt) as LoginInfo\n        if (loginInfo.domain === undefined) {\n            console.log(\"Rejecting legacy token without domain\")\n            return null\n        }\n        return loginInfo\n    } catch (e) {\n        console.warn(\"Token verification failed\", jwt, e)\n    }\n    return null\n}\n\nexport function setAuthenticatedUser(req: IncomingMessage, res: ServerResponse, userInfo: OAuthAuthenticatedUser) {\n    const loginInfo: LoginInfo = { ...userInfo, timestamp: newISOTimeStamp() }\n    const jwt = JWT.sign(loginInfo, secret)\n    new Cookies(req, res).set(\"user\", jwt, {\n        maxAge: 365 * 24 * 3600 * 1000,\n        httpOnly: false,\n    }) // Max 365 days expiration\n}\n\nexport function removeAuthenticatedUser(req: IncomingMessage, res: ServerResponse) {\n    new Cookies(req, res).set(\"user\", \"\", { maxAge: 0, httpOnly: true })\n}\n"
  },
  {
    "path": "backend/src/locker.ts",
    "content": "import { Id, BoardItemEvent, isPersistableBoardItemEvent, getItemIds, ItemLocks } from \"../../common/src/domain\"\nimport { getActiveBoards, ServerSideBoardState } from \"./board-state\"\nimport { AutoExpiringMap } from \"./expiring-map\"\nimport { WsWrapper } from \"./ws-wrapper\"\n\nconst LOCK_TTL_SECONDS = 10\n\nexport function Locks(onChange: (locks: ItemLocks) => any) {\n    const locks = AutoExpiringMap<string>(LOCK_TTL_SECONDS).onChange(onChange)\n\n    return {\n        lockItem: (itemId: Id, sessionId: Id) => {\n            if (locks.has(itemId) && locks.get(itemId) !== sessionId) {\n                return false\n            }\n\n            locks.set(itemId, sessionId)\n            return true\n        },\n        unlockItem: (itemId: Id, sessionId: Id) => {\n            if (locks.get(itemId) === sessionId) {\n                return locks.delete(itemId)\n            }\n\n            return false\n        },\n        delete: (itemId: string) => locks.delete(itemId),\n        entries: () => locks.entries(),\n    }\n}\n\nexport function obtainLock(locks: ServerSideBoardState[\"locks\"], e: BoardItemEvent, socket: WsWrapper) {\n    if (isPersistableBoardItemEvent(e)) {\n        const itemIds = getItemIds(e)\n        // Since we are operating on multiple items at a time, locking must succeed for all of them\n        // for the action to succeed\n        return itemIds.every((id) => locks.lockItem(id, socket.id))\n    } else {\n        const { itemId, action } = e\n        switch (action) {\n            case \"item.lock\":\n                return locks.lockItem(itemId, socket.id)\n            case \"item.unlock\":\n                return locks.unlockItem(itemId, socket.id)\n        }\n    }\n}\n\nexport function releaseLocksFor(socket: WsWrapper) {\n    getActiveBoards().forEach((state) => {\n        const locks = state.locks\n        for (const [itemId, sessionId] of locks.entries()) {\n            if (socket.id === sessionId) {\n                locks.delete(itemId)\n            }\n        }\n    })\n}\n"
  },
  {
    "path": "backend/src/oauth.ts",
    "content": "import Cookies from \"cookies\"\nimport { Express, Request, Response } from \"express\"\nimport { OAuthAuthenticatedUser } from \"../../common/src/authenticated-user\"\nimport { removeAuthenticatedUser, setAuthenticatedUser } from \"./http-session\"\nimport { GoogleAuthProvider, googleConfig } from \"./google-auth\"\nimport { GenericOIDCAuthProvider, genericOIDCConfig } from \"./generic-oidc-auth\"\n\nexport interface AuthProvider {\n    getAuthPageURL: () => Promise<string>\n    getAccountFromCode: (code: string) => Promise<OAuthAuthenticatedUser>\n    logout?: (req: Request, res: Response) => Promise<void>\n}\n\nexport function setupAuth(app: Express, provider: AuthProvider) {\n    app.get(\"/login\", async (req, res) => {\n        new Cookies(req, res).set(\"returnTo\", parseReturnPath(req), {\n            maxAge: 24 * 3600 * 1000,\n            httpOnly: true,\n        }) // Max 24 hours\n        const authUrl = await provider.getAuthPageURL()\n        res.setHeader(\"content-type\", \"text/html\")\n        res.send(`Signing in...<script>document.location='${authUrl}'</script>`)\n    })\n\n    app.get(\"/logout\", async (req, res) => {\n        removeAuthenticatedUser(req, res)\n        if (provider.logout) {\n            await provider.logout(req, res)\n        } else {\n            res.redirect(parseReturnPath(req))\n        }\n    })\n\n    app.get(\"/google-callback\", async (req, res) => {\n        const code = (req.query?.code as string) || \"\"\n        const cookies = new Cookies(req, res)\n        const returnTo = cookies.get(\"returnTo\") || \"/\"\n        cookies.set(\"returnTo\", \"\", { maxAge: 0, httpOnly: true })\n        console.log(\"Verifying google auth\", code)\n        try {\n            const userInfo = await provider.getAccountFromCode(code)\n            console.log(\"Found\", userInfo)\n            setAuthenticatedUser(req, res, userInfo)\n            res.redirect(returnTo)\n        } catch (e) {\n            console.error(e)\n            res.status(500).send(\"Internal error\")\n        }\n    })\n\n    function parseReturnPath(req: Request) {\n        return (req.query.returnTo as string) || \"/\"\n    }\n}\n\nexport const authProvider: AuthProvider | null = googleConfig\n    ? GoogleAuthProvider(googleConfig)\n    : genericOIDCConfig\n    ? GenericOIDCAuthProvider(genericOIDCConfig)\n    : null\n"
  },
  {
    "path": "backend/src/professions.ts",
    "content": "export const professions = [\n    \"Accountant\",\n    \"Actress\",\n    \"Architect\",\n    \"Astronomer\",\n    \"Author\",\n    \"Baker\",\n    \"Bricklayer\",\n    \"Bus driver\",\n    \"Butcher\",\n    \"Carpenter\",\n    \"Chef\",\n    \"Cleaner\",\n    \"Coder\",\n    \"Consultant\",\n    \"Dentist\",\n    \"Designer\",\n    \"DevOps specialist\",\n    \"Doctor\",\n    \"Dustman\",\n    \"Electrician\",\n    \"Engineer\",\n    \"Factory worker\",\n    \"Farmer\",\n    \"Fire fighter\",\n    \"Fisherman\",\n    \"Florist\",\n    \"Gardener\",\n    \"Hairdresser\",\n    \"Journalist\",\n    \"Judge\",\n    \"Lawyer\",\n    \"Lecturer\",\n    \"Librarian\",\n    \"Lifeguard\",\n    \"Mechanic\",\n    \"Model\",\n    \"Newsreader\",\n    \"Nurse\",\n    \"Optician\",\n    \"Painter\",\n    \"Pharmacist\",\n    \"Photographer\",\n    \"Physician\",\n    \"Pilot\",\n    \"Plumber\",\n    \"Pointy-haired boss\",\n    \"Politician\",\n    \"Postman\",\n    \"Product Owner\",\n    \"Real estate agent\",\n    \"Receptionist\",\n    \"Scientist\",\n    \"Scrum master\",\n    \"Secretary\",\n    \"Shop assistant\",\n    \"Tailor\",\n    \"Taxi driver\",\n    \"Teacher\",\n    \"Tester\",\n    \"Translator\",\n    \"Traffic warden\",\n    \"Travel agent\",\n    \"Vet\",\n    \"UNIX guru\",\n    \"Waiter/Waitress\",\n    \"Window cleaner\",\n]\n\nexport function randomProfession() {\n    return professions[Math.floor(Math.random() * professions.length)]\n}\n"
  },
  {
    "path": "backend/src/require-auth.ts",
    "content": "import { Express, Request, Response, NextFunction } from \"express\"\nimport { getAuthenticatedUser } from \"./http-session\"\n\nexport const REQUIRE_AUTH = process.env.REQUIRE_AUTH === \"true\"\n\nexport function possiblyRequireAuth(app: Express) {\n    if (REQUIRE_AUTH) {\n        // Require authentication for all resources except the URLs bound by setupAuth above\n        app.use(\"/\", (req: Request, res: Response, next: NextFunction) => {\n            if (!getAuthenticatedUser(req)) {\n                res.redirect(\"/login\")\n            } else {\n                next()\n            }\n        })\n    }\n}\n"
  },
  {
    "path": "backend/src/s3.ts",
    "content": "import * as AWS from \"aws-sdk\"\n\nconst s3Config = {\n    region: \"eu-north-1\",\n    apiVersion: \"2006-03-01\",\n    signatureVersion: \"v4\",\n}\n\nlet s3Instance: AWS.S3 | null = null\n\nexport const s3 = () => {\n    s3Instance = s3Instance || new AWS.S3(s3Config)\n    return s3Instance\n}\n\nexport function getSignedPutUrl(Key: string) {\n    const signedUrlExpireSeconds = 60 * 5\n\n    const url = s3().getSignedUrl(\"putObject\", {\n        Bucket: \"r-board-assets\",\n        Key,\n        Expires: signedUrlExpireSeconds,\n    })\n\n    return url\n}\n"
  },
  {
    "path": "backend/src/server.ts",
    "content": "import dotenv from \"dotenv\"\ndotenv.config()\n\nimport * as Http from \"http\"\nimport { exampleBoard } from \"../../common/src/domain\"\nimport { awaitSavingChanges } from \"./board-state\"\nimport { createBoard, fetchBoard } from \"./board-store\"\nimport { initDB } from \"./db\"\nimport { startExpressServer } from \"./express-server\"\nimport { terminateSessions } from \"./websocket-sessions\"\n\nlet stopServer: (() => void) | null = null\n\nasync function shutdown() {\n    console.log(\"Shutdown initiated. Closing sockets.\")\n    if (stopServer) stopServer()\n    terminateSessions()\n    console.log(\"Shutdown in progress. Waiting for all changes to be saved...\")\n    await awaitSavingChanges()\n    console.log(\"Shutdown complete. Exiting process.\")\n    process.exit(0)\n}\n\nprocess.on(\"SIGTERM\", () => {\n    console.log(\"Received SIGTERM. Initiating shutdown.\")\n    shutdown()\n})\n\nconst PORT = parseInt(process.env.PORT || \"1337\")\nconst HTTPS_PORT = process.env.HTTPS_PORT ? parseInt(process.env.HTTPS_PORT) : undefined\nconst BIND_UWEBSOCKETS_TO_PORT = process.env.BIND_UWEBSOCKETS_TO_PORT === \"true\"\nif (BIND_UWEBSOCKETS_TO_PORT && process.env.UWEBSOCKETS_PORT) {\n    throw Error(\"Cannot have both UWEBSOCKETS_PORT and BIND_UWEBSOCKETS_TO_PORT envs\")\n}\nconst HTTP_PORT = BIND_UWEBSOCKETS_TO_PORT ? null : PORT\nconst UWEBSOCKETS_PORT = BIND_UWEBSOCKETS_TO_PORT\n    ? PORT\n    : process.env.UWEBSOCKETS_PORT\n    ? parseInt(process.env.UWEBSOCKETS_PORT)\n    : null\n\ninitDB()\n    .then(async () => {\n        if (!(await fetchBoard(\"default\"))) {\n            await createBoard(exampleBoard)\n        }\n    })\n    .then(() => {\n        if (HTTP_PORT) {\n            stopServer = startExpressServer(HTTP_PORT, HTTPS_PORT)\n        }\n        if (UWEBSOCKETS_PORT) {\n            import(\"./uwebsockets-server\").then((uwebsockets) => {\n                uwebsockets.startUWebSocketsServer(UWEBSOCKETS_PORT)\n            })\n        }\n    })\n    .catch((e) => {\n        console.error(e)\n    })\n"
  },
  {
    "path": "backend/src/storage.ts",
    "content": "import { getSignedPutUrl as s3GetSignedPutUrl } from \"./s3\"\nimport { StorageBackend } from \"./config\"\n\nfunction localFSGetSignedPutUrl(Key: string): string {\n    return \"/assets/\" + Key\n}\n\nexport const createGetSignedPutUrl = (storageBackend: StorageBackend): ((key: string) => string) =>\n    storageBackend.type === \"AWS\" ? s3GetSignedPutUrl : localFSGetSignedPutUrl\n"
  },
  {
    "path": "backend/src/tools/wait-for-db.ts",
    "content": "import TcpPortUsed from \"tcp-port-used\"\nconst port = 13338\n;(async function () {\n    console.log(`Waiting for DB to bind port ${port}...`)\n    try {\n        await TcpPortUsed.waitUntilUsed(port, 100, 10000)\n    } catch {\n        console.error(\"Timed out waiting for DB\")\n        process.exit(1)\n    }\n})()\n"
  },
  {
    "path": "backend/src/user-store.ts",
    "content": "import { inTransaction, withDBClient } from \"./db\"\nimport * as uuid from \"uuid\"\nimport { EventUserInfo, Id, RecentBoard, EventUserInfoAuthenticated, ISOTimeStamp } from \"../../common/src/domain\"\nimport { uniqBy } from \"lodash\"\n\nexport function getUserIdForEmail(email: string): Promise<string> {\n    return inTransaction(async (client) => {\n        let id: string | undefined = (await client.query(\"SELECT id FROM app_user WHERE email=$1\", [email])).rows[0]?.id\n        if (!id) {\n            id = uuid.v4()\n            await client.query(\"INSERT INTO app_user (id, email) VALUES ($1, $2);\", [id, email])\n        }\n        return id\n    })\n}\n\nexport async function associateUserWithBoard(\n    userId: string,\n    boardId: Id,\n    lastOpened: ISOTimeStamp = new Date().toISOString(),\n) {\n    try {\n        await inTransaction(async (client) => {\n            await client.query(\n                `INSERT INTO user_board (user_id, board_id, last_opened) values ($1, $2, $3) \n                 ON CONFLICT (user_id, board_id) DO UPDATE SET last_opened=EXCLUDED.last_opened`,\n                [userId, boardId, lastOpened],\n            )\n        })\n    } catch (e) {\n        console.error(`Failed to associate user ${userId} with board ${boardId}`)\n    }\n}\n\nexport async function dissociateUserWithBoard(userId: string, boardId: Id) {\n    try {\n        await inTransaction(async (client) => {\n            await client.query(`DELETE FROM user_board WHERE user_id=$1 and board_id=$2`, [userId, boardId])\n        })\n    } catch (e) {\n        console.error(`Failed to dissociate user ${userId} with board ${boardId}`)\n    }\n}\n\nexport async function getUserAssociatedBoards(user: EventUserInfoAuthenticated): Promise<RecentBoard[]> {\n    const rows = (\n        await withDBClient((client) =>\n            client.query(\n                \"SELECT b.id, b.name, ub.last_opened FROM user_board ub JOIN board b on (ub.board_id = b.id) WHERE ub.user_id = $1\",\n                [user.userId],\n            ),\n        )\n    ).rows\n    return rows.map((r) => {\n        return {\n            id: r.id,\n            name: r.name,\n            userEmail: user.email,\n            opened: r.last_opened.toISOString(),\n        }\n    })\n}\n"
  },
  {
    "path": "backend/src/uwebsockets-server.ts",
    "content": "import uws from \"uWebSockets.js\"\nimport { EventFromServer } from \"../../common/src/domain\"\nimport * as uuid from \"uuid\"\nimport { connectionHandler, MessageHandler } from \"./connection-handler\"\nimport { handleBoardEvent } from \"./board-event-handler\"\nimport { createGetSignedPutUrl } from \"./storage\"\nimport { getConfig } from \"./config\"\nimport * as L from \"lonna\"\nimport { handleCommonEvent } from \"./common-event-handler\"\n\nexport const WsWrapper = (ws: uws.WebSocket) => {\n    const errorE = L.bus<void>()\n    const closeE = L.bus<void>()\n    const msgE = L.bus<object>()\n\n    const onError = (f: () => void) => {\n        errorE.forEach(f)\n    }\n    const onMessage = (f: (msg: object) => void) => {\n        msgE.forEach(f)\n    }\n    const onClose = (f: () => void) => {\n        closeE.forEach(f)\n    }\n    return {\n        send: (buffer: Buffer) => {\n            try {\n                ws.send(buffer, false)\n            } catch (e) {\n                ws.close()\n            }\n        },\n        onError,\n        onMessage,\n        onClose,\n        id: uuid.v4(),\n        close: () => {\n            ws.close()\n            closeE.push()\n        },\n        errorE,\n        closeE,\n        msgE,\n    }\n}\ntype WsWrapper = ReturnType<typeof WsWrapper>\n\ntype WsUserData = {\n    handler: MessageHandler\n}\n\nexport function startUWebSocketsServer(port: number) {\n    const config = getConfig()\n    const app = uws.App()\n\n    const sockets = new Map<uws.WebSocket, WsWrapper>()\n    const textDecoder = new TextDecoder()\n\n    const signedPutUrl = createGetSignedPutUrl(config.storageBackend)\n    mountWs(\"/socket/lobby\", () => handleBoardEvent(null, signedPutUrl))\n    mountWs(\"/socket/board/:boardId\", (req) => handleBoardEvent(req.getParameter(0), signedPutUrl))\n\n    app.get(\"/\", (res) => res.writeStatus(\"200 OK\").end(\"Sorry, we only serve websocket clients here.\"))\n\n    function mountWs(path: string, f: (req: uws.HttpRequest) => MessageHandler) {\n        app.ws(path, {\n            upgrade: (res, req, context) => {\n                res.upgrade(\n                    {\n                        handler: f(req),\n                    } as WsUserData,\n                    req.getHeader(\"sec-websocket-key\"),\n                    req.getHeader(\"sec-websocket-protocol\"),\n                    req.getHeader(\"sec-websocket-extensions\"),\n                    // 3 headers are used to setup websocket\n                    context,\n                )\n            },\n            open: (ws) => {\n                const wrapper = WsWrapper(ws)\n                const handler = (ws as WsUserData & uws.WebSocket).handler\n                sockets.set(ws, wrapper)\n                connectionHandler(wrapper, handler)\n            },\n            message: (ws, message, isBinary) => {\n                if (isBinary) throw Error(\"Binary message\")\n                const object = JSON.parse(textDecoder.decode(message))\n                const wrapper = sockets.get(ws)\n                if (!wrapper) {\n                    throw Error(\"Wrapper not found for socket \" + ws)\n                }\n                wrapper.msgE.push(object)\n            },\n            close: (ws) => {\n                const wrapper = sockets.get(ws)\n                if (wrapper) {\n                    wrapper.closeE.push()\n                }\n            },\n        })\n    }\n    app.listen(port, () => {\n        console.log(\"uWebSockets listening on \" + port)\n    })\n}\n"
  },
  {
    "path": "backend/src/websocket-sessions.ts",
    "content": "import { OAuthAuthenticatedUser } from \"../../common/src/authenticated-user\"\nimport {\n    AccessLevel,\n    AckJoinBoard,\n    AuthLogout,\n    BoardHistoryEntry,\n    CURSOR_POSITIONS_ACTION_TYPE,\n    EventFromServer,\n    EventUserInfoAuthenticated,\n    Id,\n    ItemLocks,\n    JoinedBoard,\n    Serial,\n    SessionUserInfo,\n    SetNickname,\n    UnidentifiedUserInfo,\n    UserCursorPosition,\n    UserInfoUpdate,\n    getBoardAttributes,\n    isBoardHistoryEntry,\n} from \"../../common/src/domain\"\nimport { ServerSideBoardState, maybeGetBoard } from \"./board-state\"\nimport { getBoardHistory } from \"./board-store\"\nimport { closeYjsSocketsBySessionId } from \"./board-yjs-server\"\nimport { randomProfession } from \"./professions\"\nimport { getUserIdForEmail } from \"./user-store\"\nimport { WsWrapper, toBuffer } from \"./ws-wrapper\"\n\nexport type UserSession = {\n    readonly sessionId: Id\n    boardSession: UserSessionBoardEntry | null\n    userInfo: SessionUserInfo\n    sendEvent: (event: EventFromServer) => void\n    isOnBoard: (boardId: Id) => boolean\n    close(): void\n}\n\nexport type UserSessionBoardEntry = {\n    boardId: Id\n    status: \"ready\" | \"buffering\"\n    accessLevel: AccessLevel\n    bufferedEvents: BoardHistoryEntry[]\n}\n\n/*\nsocket: WsWrapper\n    boards: Id[]\n    userInfo: EventUserInfo\n    */\nexport type SocketId = string\n\nconst sessions: Record<SocketId, UserSession> = {}\n\nconst everyoneOnTheBoard = (boardId: string) => {\n    const boardState = maybeGetBoard(boardId)\n    if (!boardState) {\n        console.warn(`Trying to send to a board not in memory: ${boardId}`)\n        return []\n    }\n    return boardState.sessions\n}\nconst sendTo = (recipients: UserSession[], message: EventFromServer) => {\n    recipients.forEach((c) => c.sendEvent(message))\n}\nconst everyoneElseOnTheSameBoard = (boardId: Id, session?: UserSession) =>\n    everyoneOnTheBoard(boardId).filter((s) => s !== session)\n\nexport function startSession(socket: WsWrapper) {\n    sessions[socket.id] = userSession(socket)\n}\n\nfunction userSession(socket: WsWrapper): UserSession {\n    const sessionId = socket.id\n\n    function sendEvent(event: EventFromServer) {\n        if (isBoardHistoryEntry(event)) {\n            const entry = session.boardSession\n            if (!entry) throw Error(\"Board \" + event.boardId + \" not found for session \" + sessionId)\n            if (entry.status === \"buffering\") {\n                entry.bufferedEvents.push(event)\n                return\n            }\n        }\n        socket.send(toBuffer(event))\n    }\n    const session: UserSession = {\n        sessionId,\n        userInfo: anonymousUser(\"Anonymous \" + randomProfession()),\n        boardSession: null,\n        sendEvent,\n        isOnBoard: (boardId: Id) => session.boardSession != null && session.boardSession.boardId === boardId,\n        close: () => socket.close(),\n    }\n    sessions[socket.id] = session\n    return session\n}\n\nfunction anonymousUser(nickname: string): UnidentifiedUserInfo {\n    return { userType: \"unidentified\", nickname }\n}\n\nexport function endSession(socket: WsWrapper) {\n    const sessionId = socket.id\n    const session = sessions[sessionId]\n    if (!session) {\n        console.warn(`Ending non-existing session ${sessionId}`)\n        return\n    }\n    if (session.boardSession) {\n        const boardState = maybeGetBoard(session.boardSession.boardId)\n        if (boardState) {\n            boardState.sessions = boardState.sessions.filter((s) => s.sessionId !== sessionId)\n            broadcastBoardEvent({ action: \"board.left\", boardId: boardState.board.id, sessionId })\n        } else {\n            console.warn(`Board state not found when ending session: ${session.boardSession.boardId}`)\n        }\n    }\n    delete sessions[socket.id]\n    closeYjsSocketsBySessionId(sessionId)\n}\nexport function getBoardSessionCount(id: Id) {\n    return everyoneOnTheBoard(id).length\n}\nexport function getSession(socket: WsWrapper): UserSession | undefined {\n    return getSessionById(socket.id)\n}\n\nexport function getSessionById(sessionId: string): UserSession | undefined {\n    return sessions[sessionId]\n}\n\nexport function terminateSessions() {\n    Object.values(sessions).forEach((session) => session.close())\n}\n\nexport async function addSessionToBoard(\n    boardState: ServerSideBoardState,\n    origin: WsWrapper,\n    accessLevel: AccessLevel,\n    initAtSerial?: Serial,\n): Promise<void> {\n    const session = sessions[origin.id]\n    if (!session) throw new Error(\"No session found for socket \" + origin.id)\n    const boardId = boardState.board.id\n    if (!boardState.sessions.includes(session)) {\n        boardState.sessions = [...boardState.sessions, session]\n    }\n    const initDiff = initAtSerial && boardState.board.serial - initAtSerial\n    if (initDiff && initDiff > Object.keys(boardState.board.items).length) {\n        console.log(`Sending fresh board state for board ${boardId} instead of diff (${initDiff} events to sync)`)\n        initAsNew(session, boardId, accessLevel, boardState)\n    } else if (initAtSerial) {\n        const entry: UserSessionBoardEntry = { boardId, status: \"buffering\", accessLevel, bufferedEvents: [] }\n        // 1. Add session to the board with \"buffering\" status, to collect all events that were meant to be sent during this async initialization\n        session.boardSession = entry\n        try {\n            const boardAttributes = getBoardAttributes(boardState.board, session.userInfo)\n\n            //console.log(`Starting session at ${initAtSerial}`)\n            // 2. capture all board events that haven't yet been flushed to the DB\n            const inMemoryEvents = (boardState.currentlyStoring?.events ?? [])\n                .concat(boardState.recentEvents)\n                .filter((e) => e.serial! > initAtSerial)\n\n            // 3. Fetch events from DB as chunks\n            // IMPORTANT NOTE: this is the only await here and must remain so, as the logic here depends on everything else being synchronous.\n            console.log(`Loading board history for board ${boardState.board.id} session at serial ${initAtSerial}`)\n            let first = true\n            await getBoardHistory(boardState.board.id, initAtSerial, (chunk) => {\n                // Send a chunk of events with done: false, so that client knows to wait for more\n                session.sendEvent({\n                    action: \"board.init.diff\",\n                    first,\n                    last: false,\n                    boardAttributes,\n                    recentEvents: chunk,\n                    initAtSerial,\n                    accessLevel,\n                })\n                first = false\n            })\n\n            console.log(`Got board history for board ${boardState.board.id} session at serial ${initAtSerial}`)\n\n            // 4. Send the last chunk containing both the inMemoryEvents and the buffered events (done: true)\n            // In memory events: not yet flushed to DB when query was made\n            // Buffered events: events that occurred after the in memory events were captured\n            session.sendEvent({\n                action: \"board.init.diff\",\n                boardAttributes,\n                first,\n                last: true,\n                recentEvents: [...inMemoryEvents, ...entry.bufferedEvents],\n                initAtSerial,\n                accessLevel,\n            })\n\n            // 5. Set the client to \"ready\" status so that new events will be flushed\n            entry.status = \"ready\"\n            entry.bufferedEvents = []\n        } catch (e) {\n            console.warn(\n                `Failed to bootstrap client on board ${boardId} at serial ${initAtSerial}. Sending full state.`,\n            )\n            entry.status = \"ready\"\n            entry.bufferedEvents = []\n            session.sendEvent({\n                action: \"board.init\",\n                board: boardState.board,\n                accessLevel,\n            })\n        }\n    } else {\n        initAsNew(session, boardId, accessLevel, boardState)\n    }\n\n    // TODO SECURITY: don't reveal authenticated emails to unidentified users on same board\n    // TODO: what to include in joined events? Not just nickname, as we want to show who's identified (beside the cursor)\n\n    session.sendEvent({\n        action: \"board.join.ack\",\n        boardId: boardState.board.id,\n        sessionId: session.sessionId,\n        nickname: session.userInfo.nickname,\n    } as AckJoinBoard)\n\n    // Notify new user of existing users\n    everyoneOnTheBoard(boardState.board.id).forEach((s) => {\n        session.sendEvent({\n            action: \"board.joined\",\n            boardId: boardState.board.id,\n            sessionId: s.sessionId,\n            ...s.userInfo,\n        } as JoinedBoard)\n    })\n\n    // Notify existing users of new user\n    broadcastJoinEvent(boardState.board.id, session)\n}\n\nfunction initAsNew(session: UserSession, boardId: string, accessLevel: AccessLevel, boardState: ServerSideBoardState) {\n    session.boardSession = { boardId, status: \"ready\", accessLevel, bufferedEvents: [] }\n    session.sendEvent({\n        action: \"board.init\",\n        board: boardState.board,\n        accessLevel,\n    })\n}\n\nexport function setNicknameForSession(event: SetNickname, origin: WsWrapper) {\n    const session = getSession(origin)\n    if (!session) {\n        console.warn(`Session not found: ${origin.id}`)\n        return\n    }\n\n    session.userInfo =\n        session.userInfo.userType === \"unidentified\"\n            ? anonymousUser(event.nickname)\n            : { ...session.userInfo, nickname: event.nickname }\n    const updateInfo: UserInfoUpdate = {\n        action: \"userinfo.set\",\n        sessionId: session.sessionId,\n        ...session.userInfo,\n    }\n    if (session.boardSession) {\n        sendTo(everyoneOnTheBoard(session.boardSession.boardId), updateInfo)\n    }\n}\n\nexport async function setVerifiedUserForSession(\n    event: OAuthAuthenticatedUser,\n    session: UserSession,\n): Promise<EventUserInfoAuthenticated> {\n    const userId = await getUserIdForEmail(event.email)\n    session.userInfo = {\n        userType: \"authenticated\",\n        nickname: event.name,\n        name: event.name,\n        email: event.email,\n        picture: event.picture,\n        domain: event.domain,\n        userId,\n    }\n    if (session.boardSession) {\n        // TODO SECURITY: don't reveal authenticated emails to unidentified users on same board\n        sendTo(everyoneElseOnTheSameBoard(session.boardSession.boardId, session), {\n            action: \"user.login\",\n            email: event.email,\n            name: event.name,\n            picture: event.picture,\n        })\n    }\n    return session.userInfo\n}\n\nexport function logoutUser(event: AuthLogout, origin: WsWrapper) {\n    const session = getSession(origin)\n    if (!session) {\n        console.warn(\"Session not found for socket \" + origin.id)\n    } else {\n        session.userInfo = { userType: \"unidentified\", nickname: session.userInfo.nickname }\n    }\n}\n\nexport function broadcastBoardEvent(event: EventFromServer & { boardId: string }, origin?: UserSession) {\n    //console.log(\"Broadcast\", event.action, \"to\", everyoneElseOnTheSameBoard(event.boardId, origin).length)\n    sendTo(everyoneElseOnTheSameBoard(event.boardId, origin), event)\n}\n\nexport function broadcastJoinEvent(boardId: Id, session: UserSession) {\n    sendTo(everyoneElseOnTheSameBoard(boardId, session), {\n        action: \"board.joined\",\n        boardId,\n        sessionId: session.sessionId,\n        ...session.userInfo,\n    } as JoinedBoard)\n}\n\nexport function broadcastCursorPositions(boardId: Id, positions: Record<Id, UserCursorPosition>) {\n    sendTo(everyoneOnTheBoard(boardId), { action: CURSOR_POSITIONS_ACTION_TYPE, p: positions })\n}\n\nconst BROADCAST_DEBOUNCE_MS = 20\n\n// Debounce by 20ms per board id, otherwise every item interaction (e.g. drag on 10 items, one event each) broadcasts locks\nexport const broadcastItemLocks = (() => {\n    let timeouts: Record<Id, NodeJS.Timeout | undefined> = {}\n    const hasActiveTimer = (boardId: string) => timeouts[boardId] !== undefined\n\n    return function _broadcastItemLocks(boardId: string, locks: ItemLocks) {\n        if (hasActiveTimer(boardId)) {\n            return\n        }\n        timeouts[boardId] = setTimeout(() => {\n            const boardState = maybeGetBoard(boardId)\n            if (boardState) {\n                sendTo(boardState.sessions, { action: \"board.locks\", boardId, locks })\n            }\n            timeouts[boardId] = undefined\n        }, BROADCAST_DEBOUNCE_MS)\n    }\n})()\n\nexport function getSessionCount() {\n    return Object.values(sessions).length\n}\n"
  },
  {
    "path": "backend/src/ws-wrapper.ts",
    "content": "import * as WebSocket from \"ws\"\nimport * as uuid from \"uuid\"\nimport { EventFromServer } from \"../../common/src/domain\"\n\nexport const WsWrapper = (ws: WebSocket) => {\n    const onError = (f: () => void) => {\n        ws.addEventListener(\"error\", f)\n    }\n    const onMessage = (f: (msg: object) => void) => {\n        ws.addEventListener(\"message\", (msg: any) => {\n            try {\n                f(JSON.parse(msg.data))\n            } catch (e) {\n                console.error(\"Error in WsWrapper/onMessage. Closing connection.\", e)\n                ws.close()\n            }\n        })\n    }\n    const onClose = (f: () => void) => {\n        ws.addEventListener(\"close\", f)\n    }\n    return {\n        send: (buffer: Buffer) => {\n            try {\n                ws.send(buffer, { binary: false })\n            } catch (e) {\n                ws.close()\n            }\n        },\n        onError,\n        onMessage,\n        onClose,\n        id: uuid.v4(),\n        close: () => ws.close(),\n    }\n}\nexport type WsWrapper = ReturnType<typeof WsWrapper>\n\ntype CachedBuffer = { msg: EventFromServer; buffer: Buffer }\nlet cachedBuffer: CachedBuffer | null = null\nexport function toBuffer(msg: EventFromServer) {\n    // We cache the latest buffer to avoid creating a new buffer for every message.\n    if (cachedBuffer && cachedBuffer.msg === msg) {\n        return cachedBuffer.buffer\n    }\n    cachedBuffer = { msg, buffer: Buffer.from(JSON.stringify(msg)) }\n    return cachedBuffer.buffer\n}\n"
  },
  {
    "path": "backend/src/y-websocket-server/Docs.ts",
    "content": "import { WSSharedDoc } from \"./WSSharedDoc\"\nimport { Persistence } from \"./Persistence\"\n\nexport interface DocsOptions {\n    persistence?: Persistence\n    gc?: boolean\n}\n\ninterface DocState {\n    doc: WSSharedDoc\n    fetchPromise: Promise<void>\n}\n\nexport class Docs {\n    readonly docs = new Map<string, DocState>()\n    readonly persistence: Persistence | null\n    readonly gc: boolean\n\n    constructor(options: DocsOptions = {}) {\n        this.persistence = options.persistence || null\n        this.gc = options.gc ?? true\n    }\n\n    /**\n     * Gets a Y.Doc by name, whether in memory or on disk\n     */\n    getYDoc(docname: string): WSSharedDoc {\n        return this.getDocState(docname).doc\n    }\n\n    async getYDocAndWaitForFetch(docname: string): Promise<WSSharedDoc> {\n        const state = this.getDocState(docname)\n        await state.fetchPromise\n        return state.doc\n    }\n\n    private getDocState(docname: string): DocState {\n        let state = this.docs.get(docname)\n        if (!state) {\n            const doc = new WSSharedDoc(this, docname)\n            console.log(`Loading document ${doc.name} into memory`)\n            doc.gc = this.gc\n            if (this.persistence !== null) {\n                void this.persistence.bindState(docname, doc)\n            }\n            const fetchPromise =\n                this.persistence !== null ? this.persistence.bindState(docname, doc) : Promise.resolve()\n            state = { doc, fetchPromise }\n            this.docs.set(docname, state)\n        }\n        return state\n    }\n\n    deleteYDoc(doc: WSSharedDoc) {\n        console.log(`Purging document ${doc.name} from memory`)\n        this.docs.delete(doc.name)\n    }\n}\n"
  },
  {
    "path": "backend/src/y-websocket-server/Persistence.ts",
    "content": "import * as Y from \"yjs\"\n\nexport interface Persistence {\n    bindState: (docName: string, ydoc: Y.Doc) => Promise<void>\n    writeState: (docName: string, ydoc: Y.Doc) => Promise<any>\n}\n\nexport function createLevelDbPersistence(persistenceDir: string): Persistence {\n    console.info('Persisting documents to \"' + persistenceDir + '\"')\n    // @ts-ignore\n    const LeveldbPersistence = require(\"y-leveldb\").LeveldbPersistence\n    const ldb = new LeveldbPersistence(persistenceDir)\n    return {\n        bindState: async (docName, ydoc) => {\n            const persistedYdoc = await ldb.getYDoc(docName)\n            const newUpdates = Y.encodeStateAsUpdate(ydoc)\n            ldb.storeUpdate(docName, newUpdates)\n            Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc))\n            ydoc.on(\"update\", (update) => {\n                ldb.storeUpdate(docName, update)\n            })\n        },\n        writeState: async (docName, ydoc) => {},\n    }\n}\n"
  },
  {
    "path": "backend/src/y-websocket-server/Protocol.ts",
    "content": "export const messageSync = 0\nexport const messageAwareness = 1\n"
  },
  {
    "path": "backend/src/y-websocket-server/WSSharedDoc.ts",
    "content": "import * as Y from \"yjs\"\n\nimport * as awarenessProtocol from \"y-protocols/awareness\"\nimport * as syncProtocol from \"y-protocols/sync\"\n\nimport * as encoding from \"lib0/encoding\"\nimport * as WebSocket from \"ws\"\nimport { Docs } from \"./Docs\"\nimport { messageAwareness, messageSync } from \"./Protocol\"\n\nexport const wsReadyStateConnecting = 0\nexport const wsReadyStateOpen = 1\nexport const wsReadyStateClosing = 2\nexport const wsReadyStateClosed = 3\n\nexport class WSSharedDoc extends Y.Doc {\n    private docs: Docs\n    readonly name: string\n    private conns: Map<WebSocket, Set<number>> = new Map<WebSocket, Set<number>>()\n    readonly awareness = new awarenessProtocol.Awareness(this)\n\n    constructor(docs: Docs, name: string) {\n        super({ gc: docs.gc })\n        this.docs = docs\n        this.name = name\n        this.awareness.setLocalState(null)\n\n        const awarenessChangeHandler = (\n            { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },\n            conn: WebSocket,\n        ) => {\n            const changedClients = added.concat(updated, removed)\n            if (conn !== null) {\n                const connControlledIDs = this.conns.get(conn)\n                if (connControlledIDs !== undefined) {\n                    added.forEach((clientID) => {\n                        connControlledIDs.add(clientID)\n                    })\n                    removed.forEach((clientID) => {\n                        connControlledIDs.delete(clientID)\n                    })\n                }\n            }\n            const encoder = encoding.createEncoder()\n            encoding.writeVarUint(encoder, messageAwareness)\n            encoding.writeVarUint8Array(\n                encoder,\n                awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients),\n            )\n            const buff = encoding.toUint8Array(encoder)\n            this.conns.forEach((_, c) => {\n                this.send(c, buff)\n            })\n        }\n        this.awareness.on(\"update\", awarenessChangeHandler)\n\n        const updateHandler = (update: Uint8Array, origin: any, doc: WSSharedDoc) => {\n            const encoder = encoding.createEncoder()\n            encoding.writeVarUint(encoder, messageSync)\n            syncProtocol.writeUpdate(encoder, update)\n            const message = encoding.toUint8Array(encoder)\n            doc.conns.forEach((_, conn) => this.send(conn, message))\n        }\n\n        this.on(\"update\", updateHandler)\n    }\n\n    send(conn: WebSocket, m: Uint8Array) {\n        if (conn.readyState !== wsReadyStateConnecting && conn.readyState !== wsReadyStateOpen) {\n            this.closeConn(conn)\n        }\n        try {\n            conn.send(m, (err: any) => {\n                err != null && this.closeConn(conn)\n            })\n        } catch (e) {\n            console.error(\"Failed to send message to client. Closing connection.\", e)\n            this.closeConn(conn)\n        }\n    }\n\n    closeConn(conn: WebSocket) {\n        if (this.conns.has(conn)) {\n            const controlledIds = this.conns.get(conn)!\n            this.conns.delete(conn)\n            awarenessProtocol.removeAwarenessStates(this.awareness, Array.from(controlledIds), null)\n            if (this.conns.size === 0 && this.docs.persistence !== null) {\n                // if persisted, we store state and destroy ydocument\n                this.docs.persistence.writeState(this.name, this).then(() => {\n                    this.destroy()\n                })\n                this.docs.deleteYDoc(this)\n            }\n        }\n        conn.close()\n    }\n\n    addConnection(conn: WebSocket) {\n        this.conns.set(conn, new Set())\n    }\n\n    hasConnection(conn: WebSocket) {\n        return this.conns.has(conn)\n    }\n}\n"
  },
  {
    "path": "backend/src/y-websocket-server/YWebSocketServer.ts",
    "content": "import * as awarenessProtocol from \"y-protocols/awareness\"\nimport * as syncProtocol from \"y-protocols/sync\"\n\nimport * as decoding from \"lib0/decoding\"\nimport * as encoding from \"lib0/encoding\"\nimport * as WebSocket from \"ws\"\nimport { Docs, DocsOptions } from \"./Docs\"\nimport { messageAwareness, messageSync } from \"./Protocol\"\nimport { WSSharedDoc } from \"./WSSharedDoc\"\n\nconst pingTimeout = 30000\n\nexport default class YWebSocketServer {\n    docs: Docs\n    constructor(options?: DocsOptions) {\n        this.docs = new Docs(options)\n    }\n\n    async setupWSConnection(conn: WebSocket, docName: string, readOnly: boolean) {\n        conn.binaryType = \"arraybuffer\"\n        // get doc, initialize if it does not exist yet\n        const doc = this.docs.getYDoc(docName)\n\n        console.log(`YJS connection established for ${docName}`)\n\n        doc.addConnection(conn)\n        // listen and reply to events\n        conn.on(\"message\", (message: ArrayBuffer) => messageListener(conn, doc, readOnly, new Uint8Array(message)))\n\n        // Check if connection is still alive\n        let pongReceived = true\n        const pingInterval = setInterval(() => {\n            if (!pongReceived) {\n                if (doc.hasConnection(conn)) {\n                    doc.closeConn(conn)\n                }\n                clearInterval(pingInterval)\n            } else if (doc.hasConnection(conn)) {\n                pongReceived = false\n                try {\n                    conn.ping()\n                } catch (e) {\n                    doc.closeConn(conn)\n                    clearInterval(pingInterval)\n                }\n            }\n        }, pingTimeout)\n        conn.on(\"close\", () => {\n            doc.closeConn(conn)\n            clearInterval(pingInterval)\n        })\n        conn.on(\"pong\", () => {\n            pongReceived = true\n        })\n        // put the following in a variables in a block so the interval handlers don't keep in in\n        // scope\n        {\n            // send sync step 1\n            const encoder = encoding.createEncoder()\n            encoding.writeVarUint(encoder, messageSync)\n            syncProtocol.writeSyncStep1(encoder, doc)\n            doc.send(conn, encoding.toUint8Array(encoder))\n            const awarenessStates = doc.awareness.getStates()\n            if (awarenessStates.size > 0) {\n                const encoder = encoding.createEncoder()\n                encoding.writeVarUint(encoder, messageAwareness)\n                encoding.writeVarUint8Array(\n                    encoder,\n                    awarenessProtocol.encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys())),\n                )\n                doc.send(conn, encoding.toUint8Array(encoder))\n            }\n        }\n    }\n}\n\n// Read-only implementation found at https://discuss.yjs.dev/t/read-only-or-one-way-only-sync/135/3\nconst readSyncMessage = (\n    decoder: decoding.Decoder,\n    encoder: encoding.Encoder,\n    doc: WSSharedDoc,\n    readOnly = false,\n    transactionOrigin: any,\n) => {\n    const messageType = decoding.readVarUint(decoder)\n    switch (messageType) {\n        case syncProtocol.messageYjsSyncStep1:\n            syncProtocol.readSyncStep1(decoder, encoder, doc)\n            break\n        case syncProtocol.messageYjsSyncStep2:\n            if (!readOnly) syncProtocol.readSyncStep2(decoder, doc, transactionOrigin)\n            break\n        case syncProtocol.messageYjsUpdate:\n            if (!readOnly) syncProtocol.readUpdate(decoder, doc, transactionOrigin)\n            break\n        default:\n            throw new Error(\"Unknown message type\")\n    }\n    return messageType\n}\n\nconst messageListener = (conn: WebSocket, doc: WSSharedDoc, readOnly: boolean, message: Uint8Array) => {\n    try {\n        const encoder = encoding.createEncoder()\n        const decoder = decoding.createDecoder(message)\n        const messageType = decoding.readVarUint(decoder)\n        switch (messageType) {\n            case messageSync:\n                encoding.writeVarUint(encoder, messageSync)\n                readSyncMessage(decoder, encoder, doc, readOnly, conn)\n\n                // If the `encoder` only contains the type of reply message and no\n                // message, there is no need to send the message. When `encoder` only\n                // contains the type of reply, its length is 1.\n                if (encoding.length(encoder) > 1) {\n                    doc.send(conn, encoding.toUint8Array(encoder))\n                }\n                break\n            case messageAwareness: {\n                awarenessProtocol.applyAwarenessUpdate(doc.awareness, decoding.readVarUint8Array(decoder), conn)\n                break\n            }\n            default: {\n                console.warn(\"Unexpected message type\" + messageType)\n            }\n        }\n    } catch (err) {\n        console.error(err)\n        doc.emit(\"error\", [err])\n    }\n}\n"
  },
  {
    "path": "backend/tsconfig.json",
    "content": "{\n    \"extends\": \"../tsconfig\",\n    \"compilerOptions\": {\n        \"module\": \"commonjs\",\n        \"outDir\": \"./dist\",\n        \"rootDir\": \"..\",\n        \"sourceMap\": true\n    }\n}\n"
  },
  {
    "path": "benchmark/benchmark.ts",
    "content": "import { uniqueId } from \"lodash\"\nimport { arrayToRecordById } from \"../common/src/arrays\"\nimport { boardReducer } from \"../common/src/board-reducer\"\nimport { Board, Item, Note, newNote } from \"../common/src/domain\"\n\ntype Foo = Board\n\nfunction createRandomItems(count: number): Item[] {\n    const items: Item[] = []\n    for (let i = 0; i < count; i++) {\n        items.push({\n            id: uniqueId(),\n            type: \"note\",\n            color: \"yellow\",\n            height: 100,\n            width: 100,\n            x: Math.random() * 10000,\n            y: Math.random() * 10000,\n            z: 0,\n            shape: \"square\",\n            text: \"Hello world\",\n        })\n    }\n    return items\n}\n\nfunction createTestBoard(size = 1000): Board {\n    return {\n        id: uniqueId(),\n        height: 10000,\n        width: 10000,\n        serial: 0,\n        name: \"Bigass board\",\n        items: arrayToRecordById(createRandomItems(size)),\n        connections: [],\n    }\n}\n\nlet testBoards = Array.from({ length: 100 }, () => {\n    const board = createTestBoard(100)\n    const items = Object.values(board.items) as Note[]\n    return {\n        board,\n        items,\n    }\n})\n\nconsole.time(\"10000 item.updates on a randomly picked 100 item board\")\nfor (let i = 0; i < 10000; i++) {\n    let randomBoard = testBoards[Math.floor(Math.random() * testBoards.length)]\n    // const itemsArray = Object.values(randomBoard.items) as Note[]\n    const item = randomBoard.items[i % randomBoard.items.length]\n    ;[randomBoard.board] = boardReducer(randomBoard.board, {\n        boardId: randomBoard.board.id,\n        action: \"item.update\",\n        items: [\n            {\n                ...item,\n                text: \"Hello world\",\n            },\n        ],\n    })\n}\nconsole.timeEnd(\"10000 item.updates on a randomly picked 100 item board\")\n\ntestBoards = Array.from({ length: 100 }, () => {\n    const board = createTestBoard(100)\n    const items = Object.values(board.items) as Note[]\n    return {\n        board,\n        items,\n    }\n})\n\nconsole.time(\"10000 item.adds on a randomly picked 100 item board\")\nfor (let i = 0; i < 10000; i++) {\n    const randomBoard = testBoards[Math.floor(Math.random() * testBoards.length)]\n    const item = newNote(\"foo\")\n    ;[randomBoard.board] = boardReducer(randomBoard.board, {\n        boardId: randomBoard.board.id,\n        action: \"item.add\",\n        items: [item],\n        connections: [],\n    })\n}\nconsole.timeEnd(\"10000 item.adds on a randomly picked 100 item board\")\n\ntestBoards = Array.from({ length: 100 }, () => {\n    const board = createTestBoard(100)\n    const items = Object.values(board.items) as Note[]\n    return {\n        board,\n        items,\n    }\n})\n\nconsole.time(\"10000 item.moves on a randomly picked 100 item board\")\nfor (let i = 0; i < 10000; i++) {\n    const randomBoard = testBoards[Math.floor(Math.random() * testBoards.length)]\n    const item = randomBoard.items[i % randomBoard.items.length]\n    ;[randomBoard.board] = boardReducer(randomBoard.board, {\n        boardId: randomBoard.board.id,\n        action: \"item.move\",\n        items: [\n            {\n                ...item,\n                x: Math.random() * 10000,\n                y: Math.random() * 10000,\n            },\n        ],\n        connections: [],\n    })\n}\nconsole.timeEnd(\"10000 item.moves on a randomly picked 100 item board\")\n\nconsole.time(\"Reference: creating 10000 input objects and doing nothing\")\nconst noop = (item: any) => {\n    item.text\n}\nfor (let i = 0; i < 10000; i++) {\n    const item = newNote(\"foo\")\n    noop({\n        ...item,\n        text: \"Hello world\",\n    })\n}\nconsole.timeEnd(\"Reference: creating 10000 input objects and doing nothing\")\n"
  },
  {
    "path": "common/src/action-folding.ts",
    "content": "import { arrayEquals, arrayIdAndKeysMatch, arrayIdMatch, idsOf } from \"./arrays\"\nimport {\n    AppEvent,\n    BoardHistoryEntry,\n    CURSOR_POSITIONS_ACTION_TYPE,\n    MoveItem,\n    isBoardHistoryEntry,\n    isSameUser,\n} from \"./domain\"\n\ntype FoldOptions = {\n    cursorsOnly?: boolean\n}\n\nconst defaultOptions = {\n    foldAddUpdate: true,\n    cursorsOnly: false,\n}\n\nexport const CURSORS_ONLY: FoldOptions = { cursorsOnly: true }\n\nexport function foldActions(a: AppEvent, b: AppEvent, options: FoldOptions = defaultOptions): AppEvent | null {\n    if (isBoardHistoryEntry(a) && isBoardHistoryEntry(b)) {\n        if (options.cursorsOnly) return null\n        if (!isSameUser(a.user, b.user)) return null\n        const folded = foldActions_(a, b, options)\n        if (!folded) return null\n        const firstSerial = a.firstSerial ? a.firstSerial : a.serial\n        const serial = b.serial\n        return { ...(folded as BoardHistoryEntry), serial, firstSerial } as BoardHistoryEntry\n    } else {\n        return foldActions_(a, b, options)\n    }\n}\n/*\nFolding can be done if in any given state S, applying actions A and B consecutively can be replaced with a single action C.\nThis function should return that composite action or null if folding is not possible.\n*/\nexport function foldActions_(a: AppEvent, b: AppEvent, options: FoldOptions = defaultOptions): AppEvent | null {\n    if (a.action === CURSOR_POSITIONS_ACTION_TYPE && b.action === CURSOR_POSITIONS_ACTION_TYPE) {\n        return b\n    }\n    if (a.action === \"cursor.move\" && b.action === \"cursor.move\" && b.boardId === a.boardId) {\n        return b // This is a local cursor move\n    }\n    if (options.cursorsOnly) return null\n\n    if (isBoardHistoryEntry(a) && isBoardHistoryEntry(b)) {\n        if (!isSameUser(a.user, b.user)) return null\n    }\n    if (a.action === \"item.front\") {\n        if (b.action === \"item.front\" && b.boardId === a.boardId && arrayEquals(b.itemIds, a.itemIds)) return b\n    } else if (a.action === \"item.move\") {\n        if (b.action === \"item.move\" && b.boardId === a.boardId && everyMovedItemMatches(b, a)) return b\n    } else if (a.action === \"item.update\") {\n        if (\n            b.action === \"item.update\" &&\n            b.boardId === a.boardId &&\n            arrayIdAndKeysMatch(b.items, a.items) &&\n            arrayIdAndKeysMatch(b.connections ?? [], a.connections ?? [])\n        ) {\n            return b\n        }\n    } else if (a.action === \"item.lock\" || a.action === \"item.unlock\") {\n        if (b.action === a.action && b.boardId === a.boardId && b.itemId === a.itemId) return b\n    } else if (a.action === \"connection.modify\" && b.action === \"connection.modify\") {\n        if (arrayIdMatch(a.connections, b.connections)) return b\n    } else if (a.action === \"connection.modify\" && b.action === \"connection.delete\") {\n        if (arrayEquals(b.connectionIds, idsOf(a.connections))) return b\n    }\n    return null\n}\n\nfunction everyMovedItemMatches(evt: MoveItem, evt2: MoveItem) {\n    return arrayIdMatch(evt.items, evt2.items) && arrayIdMatch(evt.connections, evt2.connections)\n}\n\nexport function addOrReplaceEvent<E extends AppEvent>(event: E, q: E[], options: FoldOptions = defaultOptions): E[] {\n    for (let i = 0; i < q.length; i++) {\n        let eventInQueue = q[i]\n        const folded = foldActions(eventInQueue, event, options)\n        if (folded) {\n            return [...q.slice(0, i), folded, ...q.slice(i + 1)] as E[]\n        }\n    }\n    return q.concat(event)\n}\n"
  },
  {
    "path": "common/src/arrays.ts",
    "content": "import { isArray, isEqual } from \"lodash\"\n\nexport function toArray<T>(x: T | T[]) {\n    if (isArray(x)) return x\n    return [x]\n}\n\nexport function arrayIdMatch<T extends { id: string }>(a: T[] | T, b: T[] | T) {\n    return arrayEquals(idsOf(a), idsOf(b))\n}\n\nexport function arrayObjectKeysMatch<T extends object>(a: T[] | T, b: T[] | T) {\n    return arrayEquals(keysOf(a), keysOf(b))\n}\n\nexport function arrayIdAndKeysMatch<T extends { id: string }>(a: T[] | T, b: T[] | T) {\n    return arrayIdMatch(a, b) && arrayObjectKeysMatch(a, b)\n}\n\nexport function idsOf<T extends { id: string }>(a: T[] | T): string[] {\n    return toArray(a).map((x) => x.id)\n}\n\nexport function keysOf<T extends object>(a: T[] | T): string[][] {\n    return toArray(a).map((x) => Object.keys(x))\n}\n\nexport function arrayEquals<T>(a: T[] | T, b: T[] | T) {\n    return isEqual(toArray(a), toArray(b))\n}\n\nexport function arrayToRecordById<T extends { id: string }>(arr: T[], init: Record<string, T> = {}): Record<string, T> {\n    return arr.reduce((acc: Record<string, T>, elem: T) => {\n        acc[elem.id] = elem\n        return acc\n    }, init)\n}\n"
  },
  {
    "path": "common/src/assertNotNull.ts",
    "content": "export function assertNotNull<T>(x: T | null | undefined): T {\n    if (x === null || x === undefined) throw Error(\"Assertion failed: \" + x)\n    return x\n}\n"
  },
  {
    "path": "common/src/authenticated-user.ts",
    "content": "export type OAuthAuthenticatedUser = {\n    name: string\n    email: string\n    picture?: string\n    domain: string | null\n}\n"
  },
  {
    "path": "common/src/board-crdt-helper.ts",
    "content": "import * as Y from \"yjs\"\nimport { Board, Id, Item, QuillDelta, isTextItem } from \"./domain\"\n\nexport function getCRDTField(doc: Y.Doc, itemId: Id, fieldName: string) {\n    return doc.getText(`items.${itemId}.${fieldName}`)\n}\n\nexport function augmentBoardWithCRDT(doc: Y.Doc, board: Board): Board {\n    const items = augmentItemsWithCRDT(doc, Object.values(board.items))\n    return {\n        ...board,\n        items: Object.fromEntries(items.map((i) => [i.id, i])),\n    }\n}\n\nexport function augmentItemsWithCRDT(doc: Y.Doc, items: Item[]): Item[] {\n    return items.map((item) => {\n        if (isTextItem(item) && item.crdt) {\n            const field = getCRDTField(doc, item.id, \"text\")\n            const textAsDelta = field.toDelta() as QuillDelta\n            const text = field.toString()\n            return { ...item, textAsDelta, text }\n        }\n        return item\n    })\n}\n\nexport function importItemsIntoCRDT(doc: Y.Doc, items: Item[], options?: { fallbackToText: boolean }) {\n    for (const item of items) {\n        if (isTextItem(item) && item.crdt) {\n            if (item.textAsDelta) {\n                getCRDTField(doc, item.id, \"text\").applyDelta(item.textAsDelta)\n            } else if (options?.fallbackToText) {\n                getCRDTField(doc, item.id, \"text\").insert(0, item.text)\n            } else {\n                throw Error(\"textAsDelta is missing \")\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "common/src/board-reducer.benchmark.ts",
    "content": "import _ from \"lodash\"\nimport { NOTE_COLORS } from \"./colors\"\nimport { Board } from \"./domain\"\nimport * as uuid from \"uuid\"\nimport { boardReducer } from \"./board-reducer\"\nimport { assertNotNull } from \"./assertNotNull\"\n\nfunction createBoard(): Board {\n    const itemCount = 10000\n    const board: Board = {\n        id: \"0f5b9d6c-02c2-4b81-beb7-3a3b9035e8a2\",\n        name: \"Perf3\",\n        width: 800,\n        height: 600,\n        serial: 320577,\n        connections: [],\n        items: {},\n    }\n    for (let i = 0; i < itemCount; i++) {\n        const id = uuid.v4()\n        board.items[id] = {\n            id,\n            type: \"text\",\n            x: Math.random() * 800,\n            y: Math.random() * 600,\n            z: 3,\n            width: 5,\n            height: 5,\n            text: \"Hello world\",\n            fontSize: 12,\n            locked: false,\n            color: \"#FBFC86\",\n        }\n    }\n    return board\n}\n\nconst board = createBoard()\nconst boardId = board.id\nconst eventCount = 1000\nconst items = Object.values(board.items)\n\nconst started = new Date().getTime()\nfor (let i = 0; i < eventCount; i++) {\n    const target = assertNotNull(_.sample(items))\n    const updated = { ...target, text: \"EDIT \" + i, color: _.sample(NOTE_COLORS)?.color! }\n\n    boardReducer(\n        board,\n        {\n            action: \"item.update\",\n            boardId,\n            items: [updated],\n        },\n        { inplace: true },\n    )\n}\nconst elapsed = new Date().getTime() - started\nconsole.log(`Processed ${eventCount} events in ${elapsed}ms. (${eventCount / elapsed} events/ms)`)\n"
  },
  {
    "path": "common/src/board-reducer.ts",
    "content": "import { partition } from \"lodash\"\nimport { maybeChangeContainerForItem } from \"../../frontend/src/board/item-setcontainer\"\nimport { arrayToRecordById } from \"./arrays\"\nimport { rerouteConnection, resolveEndpoint } from \"./connection-utils\"\nimport {\n    Board,\n    Connection,\n    ConnectionEndPoint,\n    ConnectionUpdate,\n    Container,\n    findItem,\n    findItemIdsRecursively,\n    getConnection,\n    getEndPointItemId,\n    getItem,\n    Id,\n    Image,\n    isBoardHistoryEntry,\n    isContainedBy,\n    isContainer,\n    isItem,\n    isItemEndPoint,\n    isTextItem,\n    Item,\n    ItemUpdate,\n    MoveItem,\n    Note,\n    PersistableBoardItemEvent,\n    Point,\n    Text,\n    TextItem,\n    Update,\n    Video,\n} from \"./domain\"\nimport { equalRect, Rect } from \"./geometry\"\nimport {\n    BoardPermission,\n    canChangeFont,\n    canChangeShapeAndColor,\n    canChangeText,\n    canChangeTextAlign,\n    canDelete,\n    canMove,\n    nullablePermission,\n} from \"../../frontend/src/board/board-permissions\"\n\ntype BoardReducerOptions = {\n    inplace?: boolean\n    strictOnSerials?: boolean\n}\n\nexport function boardReducer(\n    board: Board,\n    event: PersistableBoardItemEvent,\n    options: BoardReducerOptions = {},\n): [Board, (() => PersistableBoardItemEvent) | null] {\n    const inplace = options.inplace ?? false\n    if (isBoardHistoryEntry(event) && event.serial) {\n        const firstSerial = event.firstSerial ? event.firstSerial : event.serial\n        if (firstSerial !== board.serial + 1) {\n            const message = `Serial skip on ${event.action}, ${board.serial} -> ${firstSerial} (firstSerial ${event.firstSerial} serial ${event.serial})`\n            if (options.strictOnSerials) {\n                throw Error(message)\n            } else {\n                console.warn(message)\n            }\n        }\n        board = { ...board, serial: event.serial }\n    }\n    //console.log(event.action, inplace)\n    switch (event.action) {\n        case \"connection.add\": {\n            const newConnections = event.connections\n\n            for (let connection of newConnections) {\n                validateConnection(board, connection)\n\n                if (board.connections.some((c) => c.id === connection.id)) {\n                    throw Error(`Connection ${connection.id} already exists on board ${board.id}`)\n                }\n            }\n\n            return [\n                {\n                    ...board,\n                    connections: applyListModification(\n                        board.connections,\n                        (cs) => {\n                            cs.push(...newConnections)\n                        },\n                        inplace,\n                    ),\n                },\n                () => ({\n                    action: \"connection.delete\",\n                    boardId: event.boardId,\n                    connectionIds: newConnections.map((c) => c.id),\n                }),\n            ]\n        }\n        case \"connection.modify\": {\n            const connections = event.connections.filter(canMove)\n\n            const existingConnections = connections.map((r) => {\n                validateConnection(board, r)\n                const existingConnection = board.connections.find((c) => c.id === r.id)\n                if (!existingConnection) {\n                    throw Error(`Trying to modify nonexisting connection ${r.id} on board ${board.id}`)\n                }\n                return existingConnection\n            })\n\n            return [\n                {\n                    ...board,\n                    connections: applyListModification(\n                        board.connections,\n                        (cs) => replaceById(cs, connections),\n                        inplace,\n                    ),\n                },\n                () => ({ action: \"connection.modify\", boardId: event.boardId, connections: existingConnections }),\n            ]\n        }\n        case \"connection.delete\": {\n            const ids = new Set(event.connectionIds)\n\n            const existingConnections = board.connections.filter((c) => ids.has(c.id) || !canDelete(c))\n\n            return [\n                { ...board, connections: board.connections.filter((c) => !ids.has(c.id)) },\n                () => ({ action: \"connection.add\", boardId: event.boardId, connections: existingConnections }),\n            ]\n        }\n        case \"board.rename\":\n            return [{ ...board, name: event.name }, null]\n        case \"board.setAccessPolicy\":\n            return [{ ...board, accessPolicy: event.accessPolicy }, null]\n        case \"item.bootstrap\":\n            //if (board.items.length > 0) throw Error(\"Trying to bootstrap non-empty board\")\n            return [{ ...board, items: event.items, connections: event.connections }, null]\n        case \"item.add\":\n            if (event.items.some((a) => board.items[a.id])) {\n                throw new Error(\"Adding duplicate item \" + JSON.stringify(event.items))\n            }\n\n            const updatedItems = applyModification(\n                board.items,\n                (items) => {\n                    event.items.forEach((item) => {\n                        if (\n                            item.containerId &&\n                            !findItem(board)(item.containerId) &&\n                            !findItem(arrayToRecordById(event.items))(item.containerId)\n                        ) {\n                            // Add item but don't try to assign to a non-existing container\n                            items[item.id] = { ...item, containerId: undefined }\n                        } else {\n                            items[item.id] = item\n                        }\n                    }, {})\n                },\n                inplace,\n            )\n\n            const boardWithAddedItems = { ...board, items: updatedItems }\n\n            const connectionsToAdd = event.connections || []\n\n            connectionsToAdd.forEach((connection) => {\n                validateConnection(boardWithAddedItems, connection)\n\n                if (board.connections.some((c) => c.id === connection.id)) {\n                    throw Error(`Connection ${connection.id} already exists on board ${board.id}`)\n                }\n            })\n\n            return [\n                {\n                    ...boardWithAddedItems,\n                    connections: applyListModification(\n                        board.connections,\n                        (cs) => {\n                            cs.push(...connectionsToAdd)\n                        },\n                        inplace,\n                    ),\n                },\n                () => ({\n                    action: \"item.delete\",\n                    boardId: board.id,\n                    itemIds: event.items.map((i) => i.id),\n                    connectionIds: event.connections.map((c) => c.id),\n                }),\n            ]\n        case \"item.font.increase\":\n            return [\n                {\n                    ...board,\n                    items: applyFontSize(\n                        board.items,\n                        1.1,\n                        filterItemIdsByPermissions(event.itemIds, board, canChangeFont),\n                        inplace,\n                    ),\n                },\n                () => ({\n                    ...event,\n                    action: \"item.font.decrease\",\n                }),\n            ]\n        case \"item.font.decrease\":\n            return [\n                {\n                    ...board,\n                    items: applyFontSize(\n                        board.items,\n                        1 / 1.1,\n                        filterItemIdsByPermissions(event.itemIds, board, canChangeFont),\n                        inplace,\n                    ),\n                },\n                () => ({\n                    ...event,\n                    action: \"item.font.increase\",\n                }),\n            ]\n        case \"item.update\": {\n            const updatedConnections = updateConnections(board, event.connections || [])\n            const updatedItems = updateItems(board, event.items, updatedConnections, inplace)\n            return [\n                {\n                    ...board,\n                    items: updatedItems,\n                    connections: updatedConnections,\n                },\n                () => ({\n                    action: \"item.update\",\n                    boardId: board.id,\n                    items: event.items.map((update) => copyMatchingKeysFromOriginal(update, getItem(board)(update.id))),\n                    connections: (event.connections || []).map((update) =>\n                        copyMatchingKeysFromOriginal(update, getConnection(board)(update.id)),\n                    ),\n                }),\n            ]\n        }\n        case \"item.move\":\n            return [\n                moveItems(board, event, inplace),\n                () => ({\n                    action: \"item.move\",\n                    boardId: board.id,\n                    items: event.items.map((i) => {\n                        const item = getItem(board)(i.id)\n                        return { id: i.id, x: item.x, y: item.y, containerId: item.containerId }\n                    }),\n                    connections: event.connections.map((c) => {\n                        const conn = getConnection(board)(c.id)\n                        const startPoint = resolveEndpoint(conn.from, board)\n                        return { id: c.id, x: startPoint.x, y: startPoint.y }\n                    }),\n                }),\n            ]\n        case \"item.delete\": {\n            const itemIds = filterItemIdsByPermissions(event.itemIds, board, canDelete)\n            const connectionIds = filterConnectionIdsByPermissions(event.connectionIds, board, canDelete)\n            const itemIdsToDelete = findItemIdsRecursively(itemIds, board)\n            const connectionIdsToDelete = new Set(connectionIds)\n            const updatedItems = inplace ? board.items : { ...board.items }\n            itemIdsToDelete.forEach((id) => {\n                delete updatedItems[id]\n            })\n\n            const [connectionsToKeep, connectionsDeleted] = partition(\n                board.connections,\n                (c) =>\n                    !connectionIdsToDelete.has(c.id) &&\n                    !(c.containerId && itemIdsToDelete.has(c.containerId)) &&\n                    (!isItemEndPoint(c.from) || !itemIdsToDelete.has(getEndPointItemId(c.from))) &&\n                    (!isItemEndPoint(c.to) || !itemIdsToDelete.has(getEndPointItemId(c.to))),\n            )\n\n            return [\n                {\n                    ...board,\n                    connections: connectionsToKeep,\n                    items: updatedItems,\n                },\n                () => ({\n                    action: \"item.add\",\n                    boardId: board.id,\n                    items: Array.from(itemIdsToDelete).map(getItem(board)),\n                    connections: connectionsDeleted,\n                }),\n            ]\n        }\n        case \"item.front\":\n            let maxZ = 0\n            let maxZCount = 0\n            const itemsList = Object.values(board.items)\n            for (let i of itemsList) {\n                if (i.z > maxZ) {\n                    maxZCount = 1\n                    maxZ = i.z\n                } else if (i.z === maxZ) {\n                    maxZCount++\n                }\n            }\n            const isFine = (item: Item) => {\n                return !event.itemIds.includes(item.id) || item.z === maxZ\n            }\n            if (maxZCount === event.itemIds.length && itemsList.every(isFine)) {\n                // Requested items already on front\n                return [board, null]\n            }\n\n            const updated = event.itemIds.reduce(\n                (acc: Record<string, Item>, id) => {\n                    const item = board.items[id]\n                    if (!item) {\n                        console.warn(`Warning: trying to \"item.front\" nonexisting item ${id} on board ${board.id}`)\n                        return acc\n                    }\n                    const u = item.type !== \"container\" ? { ...item, z: maxZ + 1 } : item\n                    acc[u.id] = u\n                    return acc\n                },\n                inplace ? board.items : {},\n            )\n\n            return [\n                {\n                    ...board,\n                    items: inplace\n                        ? board.items\n                        : {\n                              ...board.items,\n                              ...updated,\n                          },\n                },\n                null,\n            ] // TODO: return item.back\n        default:\n            console.warn(\"Unknown event\", event)\n            return [board, null]\n    }\n}\n\nfunction copyMatchingKeysFromOriginal<T extends { id: Id }>(update: Update<T>, original: T): Update<T> {\n    const keysAndValues = Object.keys(update).map((key) => [key, original[key as keyof T]])\n    const result = Object.fromEntries(keysAndValues)\n    return result\n}\n\nfunction validateConnection(board: Board, connection: Connection) {\n    validateEndPoint(board, connection, \"from\")\n    validateEndPoint(board, connection, \"to\")\n}\n\nfunction validateEndPoint(board: Board, connection: Connection, key: \"to\" | \"from\") {\n    const endPoint = connection[key]\n    if (isItemEndPoint(endPoint)) {\n        const toItem = board.items[getEndPointItemId(endPoint)]\n        if (!toItem) {\n            throw Error(`Connection ${connection.id} refers to nonexisting item ${endPoint}`)\n        }\n    }\n}\n\nfunction updateConnections(board: Board, updates: ConnectionUpdate[]): Connection[] {\n    if (updates.length === 0) return board.connections\n    updates = filterConnectionUpdatesByPermissions(updates, board)\n    const updatedConnections = updates.map((update) => {\n        const existing = board.connections.find((c) => c.id === update.id)\n        if (!existing) {\n            throw Error(`Trying to modify nonexisting connection ${update.id} on board ${board.id}`)\n        }\n        const updated = { ...existing, ...update }\n        validateConnection(board, updated)\n        return updated\n    })\n    return board.connections.map((c) => {\n        const replacement = updatedConnections.find((r) => r.id === c.id)\n        return replacement ? replacement : c\n    })\n}\n\nfunction updateItems(\n    board: Board,\n    updateList: ItemUpdate[],\n    updatedConnections: Connection[],\n    inplace: boolean,\n): Record<Id, Item> {\n    updateList = filterItemUpdatesByPermissions(updateList, board)\n    const updatedItems: Item[] = updateList.map((update) => ({ ...board.items[update.id], ...update } as Item))\n\n    const resultItems = applyModification(\n        board.items,\n        (items) => {\n            arrayToRecordById(updatedItems, items)\n        },\n        inplace,\n    )\n\n    updatedItems.filter(isContainer).forEach((container) => {\n        const previous = board.items[container.id]\n        if (previous && !equalRect(previous, container)) {\n            // Container shape changed -> check items\n            Object.values(board.items)\n                .filter(\n                    (i) =>\n                        i.containerId === container.id || // Check all previously contained items\n                        containedBy(i, container), // Check all items inside the new bounds\n                )\n                .forEach((item) => {\n                    const newContainer = maybeChangeContainerForItem(item, resultItems)\n                    if (newContainer?.id !== item.containerId) {\n                        resultItems[item.id] = { ...item, containerId: newContainer ? newContainer.id : undefined }\n                    }\n                })\n        }\n    })\n\n    function setVisibilityRecursively(parent: Item, hidden: boolean) {\n        const children = Object.values(resultItems).filter((i) => i.containerId === parent.id)\n        children.forEach((child) => {\n            const resultItem = { ...child, hidden }\n            if (!hidden && isContainer(resultItem) && resultItem.contentsHidden) {\n                resultItem.contentsHidden = false\n            }\n            resultItems[child.id] = resultItem\n            setVisibilityRecursively(child, hidden)\n        })\n    }\n\n    function adjustConnectionVisibility() {\n        updatedConnections.forEach((c, i) => {\n            let shouldHide: boolean\n            if (c.containerId) {\n                // When a connection has containerId, it should be hidden if the container is hidden (or has contentsHidden)\n                // A connection practically has containerId in case its endpoints are not attached to an item and it's contained by a container\n                const container = resultItems[c.containerId]\n                shouldHide = (container?.hidden || (isContainer(container) && container.contentsHidden)) ?? false\n            } else {\n                // In other cases, hide connections in case either end is connected to a hidden item\n                const from = resolveEndpoint(c.from, resultItems)\n                const to = resolveEndpoint(c.to, resultItems)\n                shouldHide = ((isItem(from) && from.hidden) || (isItem(to) && to.hidden)) ?? false\n            }\n            if (shouldHide !== c.hidden ?? false) {\n                updatedConnections[i] = { ...c, hidden: shouldHide }\n            }\n        })\n    }\n\n    updateList.forEach((update) => {\n        if (\"contentsHidden\" in update) {\n            const container = board.items[update.id]\n            if (container) {\n                setVisibilityRecursively(container, update.contentsHidden ?? false)\n                adjustConnectionVisibility()\n            }\n        }\n    })\n\n    return resultItems\n}\n\nfunction applyModification<T>(\n    items: Record<string, T>,\n    modification: (items: Record<string, T>) => void,\n    inplace: boolean,\n): Record<string, T> {\n    const updated = inplace ? items : { ...items }\n    modification(updated)\n    return updated\n}\n\nfunction applyListModification<T>(list: T[], modification: (list: T[]) => void, inplace: boolean) {\n    const newList = inplace ? list : [...list]\n    modification(newList)\n    return newList\n}\n\nfunction replaceById<T extends { id: Id }>(list: T[], replacements: T[]) {\n    replacements.forEach((replacement) => {\n        const index = list.findIndex((item) => item.id === replacement.id)\n        if (index === -1) {\n            throw Error(`Trying to replace nonexisting item ${replacement.id}`)\n        }\n        list[index] = replacement\n    })\n}\n\nfunction applyFontSize(items: Record<string, Item>, factor: number, itemIds: Id[], inplace: boolean) {\n    return applyModification(\n        items,\n        (items) => {\n            itemIds.forEach((id) => {\n                const u = items[id] && isTextItem(items[id]) ? (items[id] as TextItem) : null\n                if (u) {\n                    items[u.id] = {\n                        ...u,\n                        fontSize: ((u as TextItem).fontSize || 1) * factor,\n                    }\n                }\n            })\n        },\n        inplace,\n    )\n}\n\nfunction filterItemIdsByPermissions(itemIds: Id[], board: Board, permission: BoardPermission) {\n    return itemIds.filter((id) => nullablePermission(permission)(findItem(board)(id)))\n}\n\nfunction filterConnectionIdsByPermissions(connectionIds: Id[], board: Board, permission: BoardPermission) {\n    return connectionIds.filter((id) => permission(getConnection(board)(id)))\n}\n\nfunction filterMoveByPermissions(event: MoveItem, board: Board) {\n    return {\n        ...event,\n        items: event.items.filter((i) => canMove(getItem(board)(i.id))),\n        connections: event.connections.filter((c) => canMove(getConnection(board)(c.id))),\n    }\n}\n\nfunction filterItemUpdatesByPermissions(updates: ItemUpdate[], board: Board): ItemUpdate[] {\n    type AnyItemKey = keyof Note | keyof Text | keyof Container | keyof Image | keyof Video\n    const propertyToPermissionMapping: Partial<Record<AnyItemKey, BoardPermission>> = {\n        align: canChangeTextAlign,\n        color: canChangeShapeAndColor,\n        fontSize: canChangeFont,\n        x: canMove,\n        y: canMove,\n        width: canMove,\n        height: canMove,\n        text: canChangeText,\n    }\n    return updates.filter((update) => {\n        const item = findItem(board)(update.id)\n        if (!item) return false\n        const keys = Object.keys(update) as AnyItemKey[]\n        const permissionFns = keys.map((key) => propertyToPermissionMapping[key])\n        for (let fn of permissionFns) {\n            if (fn && !fn(item)) {\n                console.log(\"Deny update\", keys)\n                return false\n            }\n        }\n        return true\n    })\n}\n\nfunction filterConnectionUpdatesByPermissions(updates: ConnectionUpdate[], board: Board): ConnectionUpdate[] {\n    const propertyToPermissionMapping: Partial<Record<keyof Connection, BoardPermission>> = {\n        from: canMove,\n        to: canMove,\n        fromStyle: canChangeShapeAndColor,\n        toStyle: canChangeShapeAndColor,\n        controlPoints: canMove,\n    }\n    return updates.filter((update) => {\n        const connection = getConnection(board)(update.id)\n        const keys = Object.keys(update) as (keyof Connection)[]\n        const permissionFns = keys.map((key) => propertyToPermissionMapping[key])\n        for (let fn of permissionFns) {\n            if (fn && !fn(connection)) {\n                console.log(\"Deny update\", keys)\n                return false\n            }\n        }\n        return true\n    })\n}\n\nfunction moveItems(board: Board, event: MoveItem, inplace: boolean) {\n    event = filterMoveByPermissions(event, board)\n    const itemMoves: Record<Id, ItemMove> = {}\n    const itemsOnBoard = board.items\n    const connectionMovesInEvent = event.connections || []\n\n    for (let mainItemMove of event.items) {\n        const { id, x, y, containerId } = mainItemMove\n        const mainItem = itemsOnBoard[id]\n        if (mainItem === undefined) {\n            console.warn(\"Moving unknown item\", id)\n            continue\n        }\n        const xDiff = x - mainItem.x\n        const yDiff = y - mainItem.y\n\n        for (let movedItem of Object.values(itemsOnBoard)) {\n            const movedId = movedItem.id\n            if (movedId === id || isContainedBy(itemsOnBoard, mainItem)(movedItem)) {\n                const move = { xDiff, yDiff, containerChanged: movedId === id, containerId }\n                itemMoves[movedId] = move\n            }\n        }\n    }\n\n    const connectionMoves: Record<Id, ConnectionMove> = {}\n    for (let connection of board.connections) {\n        const move = findConnectionMove(connection, itemMoves, itemsOnBoard)\n        if (move) {\n            connectionMoves[connection.id] = move\n        } else {\n            const m = connectionMovesInEvent.find((m) => m.id === connection.id)\n            if (m) {\n                const startPoint = resolveEndpoint(connection.from, board)\n                connectionMoves[connection.id] = {\n                    ends: \"both\",\n                    xDiff: m.x - startPoint.x,\n                    yDiff: m.y - startPoint.y,\n                }\n            }\n        }\n    }\n\n    let updatedConnections: Connection[] = board.connections.flatMap((connection) => {\n        const move = connectionMoves[connection.id]\n        if (!move) return connection\n        if (move.ends === \"both\") {\n            return {\n                ...connection,\n                from: moveEndPoint(connection.from, move),\n                to: moveEndPoint(connection.to, move),\n                controlPoints: connection.controlPoints.map((cp) => moveEndPoint(cp, move)),\n            } as Connection\n        }\n        return rerouteConnection(connection, board)\n    })\n\n    const updatedItems = Object.entries(itemMoves).reduce(\n        (items, [id, move]) => {\n            const item = items[id]\n            const updated = { ...item, x: item.x + move.xDiff, y: item.y + move.yDiff }\n            if (move.containerChanged) {\n                updated.containerId = move.containerId\n                if (move.containerId) {\n                    const newContainer = board.items[move.containerId]\n                    if (\n                        newContainer &&\n                        (newContainer.hidden || (isContainer(newContainer) && newContainer.contentsHidden))\n                    ) {\n                        updated.hidden = true\n                    }\n                }\n            }\n            items[id] = updated\n            return items\n        },\n        inplace ? board.items : { ...board.items },\n    )\n\n    return {\n        ...board,\n        items: updatedItems,\n        connections: updatedConnections,\n    }\n}\n\ntype Move = { xDiff: number; yDiff: number }\ntype ItemMove = Move & { containerChanged: boolean; containerId: Id | undefined }\ntype ConnectionMove = (Move & { ends: \"both\" }) | { ends: \"one\" }\n\nfunction findConnectionMove(\n    connection: Connection,\n    moves: Record<Id, Move>,\n    items: Record<string, Item>,\n): ConnectionMove | null {\n    const endPoints = [connection.to, connection.from]\n    let move: Move | null = null\n    let partial = false\n    let hasItemEndPoints = false\n    for (let endPoint of endPoints) {\n        if (isItemEndPoint(endPoint)) {\n            hasItemEndPoints = true\n            const itemId = getEndPointItemId(endPoint)\n            if (moves[itemId]) {\n                move = moves[itemId]\n            } else {\n                // linked to item not being moved -> maybe a partial move\n                partial = true\n            }\n        }\n    }\n    if (!move && !hasItemEndPoints && connection.containerId) {\n        move = moves[connection.containerId]\n    }\n    if (!move) return null\n    if (partial) return { ends: \"one\" }\n    return { ends: \"both\", ...move }\n}\n\nfunction moveEndPoint(endPoint: ConnectionEndPoint, move: Move) {\n    if (isItemEndPoint(endPoint)) {\n        return endPoint // points to an item\n    }\n    const x = endPoint.x + move.xDiff\n    const y = endPoint.y + move.yDiff\n    return { ...endPoint, x, y }\n}\n\nfunction containedBy(a: Point, b: Rect) {\n    return a.x > b.x && a.y > b.y && a.x < b.x + b.width && a.y < b.y + b.height\n}\n"
  },
  {
    "path": "common/src/colors.ts",
    "content": "export const LIGHT_BLUE = \"#9FECFC\"\nexport const LIGHT_GREEN = \"#C8FC87\"\nexport const YELLOW = \"#FBFC86\"\nexport const ORANGE = \"#FDDF90\"\nexport const PINK = \"#FDC4E7\"\nexport const LIGHT_PURPLE = \"#E0BDFA\"\nexport const RED = \"#F62A5C\"\nexport const BLACK = \"#000000\"\nexport const LIGHT_GRAY = \"#f4f4f6\"\nexport const WHITE = \"#ffffff\"\nexport const TRANSPARENT = \"#ffffff00\"\nexport const DEFAULT_NOTE_COLOR = YELLOW\n\nexport const NOTE_COLORS = [\n    { name: \"light-blue\", color: LIGHT_BLUE },\n    { name: \"light-green\", color: LIGHT_GREEN },\n    { name: \"yellow\", color: YELLOW },\n    { name: \"orange\", color: ORANGE },\n    { name: \"pink\", color: PINK },\n    { name: \"light-purple\", color: LIGHT_PURPLE },\n    { name: \"red\", color: RED },\n    { name: \"black\", color: BLACK },\n    { name: \"light-gray\", color: LIGHT_GRAY },\n    { name: \"white\", color: WHITE },\n    { name: \"transparent\", color: TRANSPARENT },\n]\n"
  },
  {
    "path": "common/src/connection-utils.ts",
    "content": "import * as _ from \"lodash\"\nimport { maybeChangeContainerForConnection } from \"../../frontend/src/board/item-setcontainer\"\nimport {\n    AttachmentLocation,\n    AttachmentSide,\n    Board,\n    Connection,\n    ConnectionEndPoint,\n    ConnectionEndPointToItem,\n    getEndPointItemId,\n    getItem,\n    isItem,\n    isItemEndPoint,\n    Item,\n    ItemAttachmentLocation,\n    Point,\n} from \"./domain\"\nimport { centerPoint, containedBy, Rect, subtract } from \"./geometry\"\nimport { getAngleDeg, Vector2 } from \"./vector2\"\n\nexport function resolveEndpoint(e: Point | Item | ConnectionEndPoint, b: Board | Record<string, Item>): Point | Item {\n    if (isItemEndPoint(e)) {\n        return resolveItemEndpoint(e, b)\n    }\n    return e\n}\n\nexport function resolveItemEndpoint(e: ConnectionEndPointToItem, b: Board | Record<string, Item>): Item {\n    return getItem(b)(getEndPointItemId(e))\n}\n\nexport function findNearestAttachmentLocationForConnectionNode(\n    i: Point | Item,\n    reference: Point | Item,\n): AttachmentLocation {\n    if (!isItem(i)) return { side: \"none\", point: i }\n    const options: ItemAttachmentLocation[] = findItemAttachmentLocations(i)\n    const from = centerPoint(reference)\n    return withStraightestAngle(options, from)!\n}\n\nfunction angleDiff(option: ItemAttachmentLocation, from: Point) {\n    const directionFromEndPoint: Vector2 = subtract(from, option.point)\n    const endpointDirection = getEndPointDirection(option.side)\n    const diff = Math.abs(getAngleDeg(directionFromEndPoint) - getAngleDeg(endpointDirection))\n    if (diff > 180) return 360 - diff\n    return diff\n}\n\nfunction withStraightestAngle(options: ItemAttachmentLocation[], to: Point) {\n    return _.minBy(options, (p) => angleDiff(p, to))\n}\n\nfunction getEndPointDirection(side: AttachmentSide): Vector2 {\n    switch (side) {\n        case \"top\":\n            return Vector2(0, -1)\n        case \"right\":\n            return Vector2(1, 0)\n        case \"bottom\":\n            return Vector2(0, 1)\n        case \"left\":\n            return Vector2(-1, 0)\n    }\n}\n\nconst sides: AttachmentSide[] = [\"top\", \"left\", \"bottom\", \"right\"]\n\nfunction findItemAttachmentLocations(i: Item): ItemAttachmentLocation[] {\n    return sides.map((side) => findAttachmentLocation(i, side))\n}\n\nfunction p(x: number, y: number) {\n    return { x, y }\n}\n\nexport function findAttachmentLocation(i: Item, side: AttachmentSide): ItemAttachmentLocation {\n    const margin = 0.1\n    switch (side) {\n        case \"top\":\n            return { item: i, side, point: p(i.x + i.width / 2, i.y - margin) }\n        case \"left\":\n            return { item: i, side, point: p(i.x - margin, i.y + i.height / 2) }\n        case \"right\":\n            return { item: i, side, point: p(i.x + i.width + margin, i.y + i.height / 2) }\n        case \"bottom\":\n            return { item: i, side, point: p(i.x + i.width / 2, i.y + i.height + margin) }\n    }\n}\n\nfunction findMidpoint(fromCoords: AttachmentLocation, toCoords: AttachmentLocation) {\n    const midpoint = {\n        x: mid(fromCoords.point.x, toCoords.point.x),\n        y: mid(fromCoords.point.y, toCoords.point.y),\n    }\n    if (toCoords.side === \"left\" || toCoords.side === \"right\") {\n        return {\n            x: midpoint.x,\n            y: mid(midpoint.y, toCoords.point.y),\n        }\n    }\n    if (toCoords.side === \"top\" || toCoords.side === \"bottom\") {\n        return {\n            x: mid(midpoint.x, toCoords.point.x),\n            y: midpoint.y,\n        }\n    }\n    if (fromCoords.side === \"left\" || fromCoords.side === \"right\") {\n        return {\n            x: midpoint.x,\n            y: mid(midpoint.y, fromCoords.point.y),\n        }\n    }\n    if (fromCoords.side === \"top\" || fromCoords.side === \"bottom\") {\n        return {\n            x: mid(midpoint.x, fromCoords.point.x),\n            y: midpoint.y,\n        }\n    }\n    return midpoint\n}\n\nfunction attachmentLocation2EndPoint(l: AttachmentLocation): ConnectionEndPoint {\n    if (l.side === \"none\") {\n        return l.point\n    }\n    return { side: l.side, id: l.item.id }\n}\n\nexport function rerouteConnection(c: Connection, b: Board): Connection {\n    const resolvedFrom = resolveEndpoint(c.from, b)\n    const resolvedTo = resolveEndpoint(c.to, b)\n\n    let to = findNearestAttachmentLocationForConnectionNode(resolvedTo, resolvedFrom)\n    const from = findNearestAttachmentLocationForConnectionNode(resolvedFrom, to.point)\n    to = findNearestAttachmentLocationForConnectionNode(resolvedTo, from.point)\n\n    const rerouted: Connection = {\n        ...c,\n        from: attachmentLocation2EndPoint(from),\n        to: attachmentLocation2EndPoint(to),\n        controlPoints: c.controlPoints.length ? [findMidpoint(from, to)] : [],\n    }\n\n    const container = maybeChangeContainerForConnection(rerouted, b.items)\n\n    return { ...rerouted, containerId: container ? container.id : undefined }\n}\n\nfunction rerouteEndPoint(e: ConnectionEndPoint, from: ConnectionEndPoint, b: Board) {\n    return attachmentLocation2EndPoint(\n        findNearestAttachmentLocationForConnectionNode(resolveEndpoint(e, b), resolveEndpoint(from, b)),\n    )\n}\n\nexport function rerouteByNewControlPoints(c: Connection, controlPoints: Point[], b: Board): Connection {\n    const first = controlPoints[0]\n\n    return {\n        ...c,\n        from: rerouteEndPoint(c.from, controlPoints[0] || c.to, b),\n        to: rerouteEndPoint(c.to, controlPoints[controlPoints.length - 1] || c.from, b),\n        controlPoints,\n    }\n}\n\nfunction mid(x: number, y: number) {\n    return (x + y) * 0.5\n}\n\nexport const connectionRect = (b: Board | Record<string, Item>) => (c: Connection): Rect => {\n    const start = resolveEndpoint(c.from, b)\n    const end = resolveEndpoint(c.to, b)\n    const allPoints = [start, ...c.controlPoints, end]\n    const minX = _.min(allPoints.map((p) => p.x))!\n    const maxX = _.max(allPoints.map((p) => p.x))!\n    const minY = _.min(allPoints.map((p) => p.y))!\n    const maxY = _.max(allPoints.map((p) => p.y))!\n    const x = minX\n    if (isNaN(x)) {\n        throw Error(\"Assertion fail\")\n    }\n    const y = minY\n    const width = maxX - minX\n    const height = maxY - minY\n    return { x, y, width, height }\n}\n\nexport function isFullyContainedConnection(connection: Connection, item: Item, context: Record<string, Item> | Board) {\n    const start = resolveEndpoint(connection.from, context)\n    const end = resolveEndpoint(connection.to, context)\n    return !isItem(start) && !isItem(end) && containedBy(start, item) && containedBy(end, item)\n}\n"
  },
  {
    "path": "common/src/domain.ts",
    "content": "import * as t from \"io-ts\"\nimport * as uuid from \"uuid\"\nimport { LocalStorageBoard } from \"../../frontend/src/store/board-local-store\"\nimport { arrayToRecordById } from \"./arrays\"\nimport { DEFAULT_NOTE_COLOR, LIGHT_BLUE, PINK, RED } from \"./colors\"\nimport { Rect } from \"./geometry\"\n\nexport type Id = string\nexport type ISOTimeStamp = string\n\nexport function newISOTimeStamp(): ISOTimeStamp {\n    return new Date().toISOString()\n}\n\nexport function optional<T extends t.Type<any>>(c: T) {\n    return t.union([c, t.undefined, t.null])\n}\n\nexport const CrdtDisabled = undefined\nexport const CrdtEnabled = 1 as const\nexport type CrdtMode = typeof CrdtDisabled | typeof CrdtEnabled\n\nexport type BoardAttributes = {\n    id: Id\n    name: string\n    width: number\n    height: number\n    accessPolicy?: BoardAccessPolicy\n    crdt?: CrdtMode\n}\n\nexport type BoardContents = {\n    items: Record<Id, Item>\n    connections: Connection[]\n}\n\nexport type Board = BoardAttributes &\n    BoardContents & {\n        serial: Serial\n    }\n\nexport type BoardStub = Pick<Board, \"id\" | \"name\" | \"accessPolicy\" | \"crdt\"> & { templateId?: Id }\n\nexport const AccessLevelCodec = t.union([\n    t.literal(\"admin\"),\n    t.literal(\"read-write\"),\n    t.literal(\"read-only\"),\n    t.literal(\"none\"),\n])\nexport type AccessLevel = t.TypeOf<typeof AccessLevelCodec>\nexport const AccessListEntryCodec = t.union([\n    t.type({\n        email: t.string,\n        access: optional(AccessLevelCodec),\n    }),\n    t.type({\n        domain: t.string,\n        access: optional(AccessLevelCodec),\n    }),\n])\nexport type AccessListEntry = t.TypeOf<typeof AccessListEntryCodec>\nexport const BoardAccessPolicyDefinedCodec = t.type({\n    allowList: t.array(AccessListEntryCodec),\n    publicRead: optional(t.boolean),\n    publicWrite: optional(t.boolean),\n})\nexport type BoardAccessPolicyDefined = t.TypeOf<typeof BoardAccessPolicyDefinedCodec>\nexport const BoardAccessPolicyCodec = t.union([t.undefined, BoardAccessPolicyDefinedCodec])\nexport type BoardAccessPolicy = t.TypeOf<typeof BoardAccessPolicyCodec>\n\nexport type EventUserInfo = UnidentifiedUserInfo | SystemUserInfo | EventUserInfoAuthenticated\n\nexport type UnidentifiedUserInfo = { nickname: string; userType: \"unidentified\" }\nexport type SystemUserInfo = { nickname: string; userType: \"system\" }\n\nexport type EventUserInfoAuthenticated = {\n    nickname: string\n    userType: \"authenticated\"\n    name: string\n    email: string\n    userId: string\n}\n\nexport type SessionUserInfo = UnidentifiedUserInfo | SystemUserInfo | SessionUserInfoAuthenticated\n\nexport type SessionUserInfoAuthenticated = {\n    nickname: string\n    userType: \"authenticated\"\n    name: string\n    email: string\n    picture: string | undefined\n    userId: string\n    domain: string | null\n}\n\nexport type UserSessionInfo = SessionUserInfo & {\n    sessionId: Id\n}\n\nexport type BoardHistoryEntry = {\n    user: EventUserInfo\n    timestamp: ISOTimeStamp\n    serial?: Serial\n    firstSerial?: Serial\n} & PersistableBoardItemEvent\nexport type BoardWithHistory = { board: Board; history: BoardHistoryEntry[] }\nexport type CompactBoardHistory = { boardAttributes: BoardAttributes; history: BoardHistoryEntry[] }\n\nexport const defaultBoardSize = { width: 800, height: 600 }\n\nexport interface CursorPosition {\n    x: number\n    y: number\n}\n\nexport type UserCursorPosition = CursorPosition & {\n    sessionId: Id\n}\n\nexport type BoardCursorPositions = Record<Id, UserCursorPosition>\n\nexport type Color = string\n\nexport type ItemBounds = { x: number; y: number; width: number; height: number; z: number }\nexport type LockState = false | \"locked\" | \"read-only\"\nexport type ItemProperties = { id: string; containerId?: string; locked: LockState; hidden?: boolean } & ItemBounds\n\nexport const ITEM_TYPES = {\n    NOTE: \"note\",\n    TEXT: \"text\",\n    IMAGE: \"image\",\n    VIDEO: \"video\",\n    CONTAINER: \"container\",\n} as const\nexport type ItemType = typeof ITEM_TYPES[keyof typeof ITEM_TYPES]\nexport type QuillDelta = any // TODO: define this properly\nexport type TextItemProperties = ItemProperties & {\n    text: string\n    fontSize?: number\n    align?: Align\n    crdt?: CrdtMode\n    textAsDelta?: QuillDelta\n}\nexport type NoteShape = \"round\" | \"square\" | \"rect\" | \"diamond\"\nexport type Note = TextItemProperties & {\n    type: typeof ITEM_TYPES.NOTE\n    color: Color\n    shape: NoteShape | undefined\n}\nexport type Text = TextItemProperties & { type: typeof ITEM_TYPES.TEXT; color: Color }\nexport type Image = ItemProperties & { type: typeof ITEM_TYPES.IMAGE; assetId: string; src?: string }\nexport type Video = ItemProperties & { type: typeof ITEM_TYPES.VIDEO; assetId: string; src?: string }\nexport type Container = TextItemProperties & {\n    type: typeof ITEM_TYPES.CONTAINER\n    color: Color\n    contentsHidden?: boolean\n}\n\nexport type Point = { x: number; y: number }\nexport function Point(x: number, y: number) {\n    return { x, y }\n}\nexport const isPoint = (u: unknown): u is Point => typeof u === \"object\" && !!u && \"x\" in u && \"y\" in u\nexport type ConnectionEndStyle = \"none\" | \"arrow\" | \"black-dot\"\nexport type Connection = {\n    id: Id\n    from: ConnectionEndPoint\n    controlPoints: Point[]\n    to: ConnectionEndPoint\n    containerId?: string\n    locked: LockState\n    fromStyle: ConnectionEndStyle\n    toStyle: ConnectionEndStyle\n    pointStyle: \"none\" | \"black-dot\"\n    action: \"connect\" | \"line\"\n    hidden?: boolean\n}\nexport type ConnectionEndPoint = Point | ConnectionEndPointToItem\nexport type ConnectionEndPointToItem = Id | ConectionEndPointDirectedToItem\nexport type ConectionEndPointDirectedToItem = { id: Id; side: AttachmentSide }\nexport function getEndPointItemId(e: ConnectionEndPointToItem) {\n    if (typeof e === \"string\") return e\n    return e.id\n}\nexport function isItemEndPoint(e: ConnectionEndPoint): e is ConnectionEndPointToItem {\n    if (typeof e === \"string\") return true\n    if (\"side\" in e) return true\n    return false\n}\nexport function isDirectedItemEndPoint(e: ConnectionEndPoint): e is ConectionEndPointDirectedToItem {\n    return isItemEndPoint(e) && typeof e === \"object\"\n}\nexport type AttachmentSide = \"left\" | \"right\" | \"top\" | \"bottom\"\nexport type AttachmentLocation = { side: \"none\"; point: Point } | ItemAttachmentLocation\nexport type ItemAttachmentLocation = { side: AttachmentSide; point: Point; item: Item }\n\nexport type RenderableConnection = Omit<Connection, \"from\" | \"to\"> & {\n    from: AttachmentLocation\n    to: AttachmentLocation\n}\n\nexport type TextItem = Note | Text | Container\nexport type ColoredItem = Item & { color: Color }\nexport type ShapedItem = Note\nexport type Item = TextItem | Image | Video\nexport type ItemLocks = Record<Id, Id>\n\nexport type RecentBoardAttributes = { id: Id; name: string }\nexport type RecentBoard = RecentBoardAttributes & { opened: ISOTimeStamp; userEmail: string | null }\n\nexport type BoardEvent = { boardId: Id }\nexport type UIEvent = BoardItemEvent | ClientToServerRequest | LocalUIEvent\nexport type LocalUIEvent = Undo | Redo | SetLocalBoard | GoOnline | BoardLoggedOut | GoOffline | TextFormat\nexport type EventFromServer = BoardHistoryEntry | BoardStateSyncEvent | LoginResponse | AckAddBoard | ServerConfig\nexport type ServerConfig = {\n    action: \"server.config\"\n    authSupported: boolean\n    assetStorageURL: string\n    crdt: \"true\" | \"false\" | \"opt-in\" | \"opt-in-authenticated\"\n}\nexport type Serial = number\nexport type AppEvent =\n    | BoardItemEvent\n    | BoardStateSyncEvent\n    | LocalUIEvent\n    | ClientToServerRequest\n    | LoginResponse\n    | AckAddBoard\n    | ServerConfig\nexport type EventWrapper = {\n    events: AppEvent[]\n    ackId?: string\n}\nexport type PersistableBoardItemEvent =\n    | AddItem\n    | UpdateItem\n    | MoveItem\n    | DeleteItem\n    | AddConnection\n    | ModifyConnection\n    | DeleteConnection\n    | IncreaseItemFont\n    | DecreaseItemFont\n    | BringItemToFront\n    | BootstrapBoard\n    | RenameBoard\n    | SetBoardAccessPolicy\nexport type BoardInit = InitBoardNew | InitBoardDiff\nexport type TransientBoardItemEvent = LockItem | UnlockItem\nexport type BoardItemEvent = PersistableBoardItemEvent | TransientBoardItemEvent\nexport type BoardStateSyncEvent =\n    | BoardInit\n    | RecentBoardsFromServer\n    | GotBoardLocks\n    | CursorPositions\n    | JoinedBoard\n    | LeftBoard\n    | UserLoggedIn\n    | AckJoinBoard\n    | DeniedJoinBoard\n    | UserInfoUpdate\n    | ActionApplyFailed\n    | AssetPutUrlResponse\n    | Ack\n    | BringAllToMe\n\nexport type ClientToServerRequest =\n    | CursorMove\n    | AddBoard\n    | LockItem\n    | UnlockItem\n    | JoinBoard\n    | AssociateBoard\n    | DissociateBoard\n    | SetNickname\n    | AssetPutUrlRequest\n    | AuthJWTLogin\n    | UserLoggedIn\n    | AuthLogout\n    | Ping\n    | BringAllToMe\n\nexport type LoginResponse =\n    | { action: \"auth.login.response\"; success: false }\n    | { action: \"auth.login.response\"; success: true; userId: string }\nexport type AddConnection = { action: \"connection.add\"; boardId: Id; connections: Connection[] }\nexport type ModifyConnection = { action: \"connection.modify\"; boardId: Id; connections: Connection[] }\nexport type DeleteConnection = { action: \"connection.delete\"; boardId: Id; connectionIds: Id[] }\nexport type UserLoggedIn = {\n    action: \"user.login\"\n    name: string\n    email: string\n    picture: string | undefined\n}\nexport type AuthJWTLogin = {\n    action: \"auth.login.jwt\"\n    jwt: string\n}\nexport type AuthLogout = { action: \"auth.logout\" }\nexport type Ping = { action: \"ping\" }\nexport type AddItem = { action: \"item.add\"; boardId: Id; items: Item[]; connections: Connection[] }\nexport type UpdateItem = { action: \"item.update\"; boardId: Id; items: ItemUpdate[]; connections?: ConnectionUpdate[] }\nexport type Update<T> = Partial<T> & { id: Id }\nexport type ItemUpdate<I extends Item = Item> = Update<I>\nexport type ConnectionUpdate = Update<Connection>\nexport type MoveItem = {\n    action: \"item.move\"\n    boardId: Id\n    items: { id: Id; x: number; y: number; containerId?: Id | undefined }[]\n    connections: { id: Id; x: number; y: number }[] // Coordinates are for connection start point.\n}\nexport type IncreaseItemFont = { action: \"item.font.increase\"; boardId: Id; itemIds: Id[] }\nexport type DecreaseItemFont = { action: \"item.font.decrease\"; boardId: Id; itemIds: Id[] }\nexport type BringItemToFront = { action: \"item.front\"; boardId: Id; itemIds: Id[] }\nexport type DeleteItem = { action: \"item.delete\"; boardId: Id; itemIds: Id[]; connectionIds: Id[] }\nexport type BootstrapBoard = { action: \"item.bootstrap\"; boardId: Id } & BoardContents\nexport type LockItem = { action: \"item.lock\"; boardId: Id; itemId: Id }\nexport type UnlockItem = { action: \"item.unlock\"; boardId: Id; itemId: Id }\nexport type GotBoardLocks = { action: \"board.locks\"; boardId: Id; locks: ItemLocks }\nexport type AddBoard = { action: \"board.add\"; payload: Board | BoardStub }\nexport type AckAddBoard = { action: \"board.add.ack\"; boardId: Id }\nexport type JoinBoard = { action: \"board.join\"; boardId: Id; initAtSerial?: Serial }\nexport type BringAllToMe = { action: \"user.bringAllToMe\"; boardId: Id; sessionId: Id; viewRect: Rect; nickname: string }\nexport type AssociateBoard = { action: \"board.associate\"; boardId: Id; lastOpened: ISOTimeStamp }\nexport type DissociateBoard = { action: \"board.dissociate\"; boardId: Id }\nexport type SetBoardAccessPolicy = {\n    action: \"board.setAccessPolicy\"\n    boardId: Id\n    accessPolicy: BoardAccessPolicyDefined\n}\nexport type AckJoinBoard = { action: \"board.join.ack\"; boardId: Id } & UserSessionInfo\nexport type DeniedJoinBoard =\n    | {\n          action: \"board.join.denied\"\n          boardId: Id\n          reason: \"unauthorized\" | \"forbidden\" | \"not found\"\n      }\n    | {\n          action: \"board.join.denied\"\n          boardId: Id\n          reason: \"redirect\"\n          wsAddress: string\n      }\nexport type RecentBoardsFromServer = { action: \"user.boards\"; email: string; boards: RecentBoard[] }\nexport type Ack = { action: \"ack\"; ackId: string; serials: Record<Id, Serial> }\nexport type ActionApplyFailed = { action: \"board.action.apply.failed\" }\nexport type JoinedBoard = { action: \"board.joined\"; boardId: Id } & UserSessionInfo\nexport type LeftBoard = { action: \"board.left\"; boardId: Id; sessionId: Id }\nexport type UserInfoUpdate = { action: \"userinfo.set\" } & UserSessionInfo\nexport type InitBoardNew = { action: \"board.init\"; board: Board; accessLevel: AccessLevel }\nexport type InitBoardDiff = {\n    action: \"board.init.diff\"\n    initAtSerial: Serial\n    first: boolean\n    last: boolean\n    recentEvents: BoardHistoryEntry[]\n    boardAttributes: BoardAttributes\n    accessLevel: AccessLevel\n}\nexport type RenameBoard = { action: \"board.rename\"; boardId: Id; name: string }\nexport type CursorMove = { action: \"cursor.move\"; position: CursorPosition; boardId: Id }\nexport type SetNickname = { action: \"nickname.set\"; nickname: string }\nexport type AssetPutUrlRequest = { action: \"asset.put.request\"; assetId: string }\nexport type AssetPutUrlResponse = { action: \"asset.put.response\"; assetId: string; signedUrl: string }\nexport type Undo = { action: \"ui.undo\" }\nexport type Redo = { action: \"ui.redo\" }\nexport type TextFormat = { action: \"ui.text.format\"; itemIds: Id[]; format: \"bold\" | \"italic\" | \"underline\" }\n\nexport type SetLocalBoard = {\n    action: \"ui.board.setLocal\"\n    boardId: Id | undefined\n    storedInitialState: LocalStorageBoard | undefined\n}\nexport type BoardLoggedOut = { action: \"ui.board.logged.out\"; boardId: Id }\nexport type GoOffline = { action: \"ui.offline\" }\nexport type GoOnline = { action: \"ui.online\" }\n\nexport const CURSOR_POSITIONS_ACTION_TYPE = \"c\" as const\nexport type CursorPositions = { action: typeof CURSOR_POSITIONS_ACTION_TYPE; p: Record<Id, UserCursorPosition> }\n\nexport const exampleBoard: Board = {\n    id: \"default\",\n    name: \"Test Board\",\n    items: arrayToRecordById([\n        newNote(\"Hello\", PINK, 10, 5),\n        newNote(\"World\", LIGHT_BLUE, 20, 10),\n        newNote(\"Welcome\", RED, 5, 14),\n    ]),\n    connections: [],\n    ...defaultBoardSize,\n    serial: 0,\n}\n\nexport function newBoard(name: string, crdt?: CrdtMode, accessPolicy?: BoardAccessPolicy): Board {\n    return { id: uuid.v4(), name, items: {}, accessPolicy, connections: [], ...defaultBoardSize, serial: 0, crdt }\n}\n\nexport function newNote(\n    text: string,\n    color: Color = DEFAULT_NOTE_COLOR,\n    x: number = 20,\n    y: number = 20,\n    width: number = 5,\n    height: number = 5,\n    shape: NoteShape = \"square\",\n    z: number = 0,\n): Note {\n    return { id: uuid.v4(), type: \"note\", text, color, x, y, width, height, z, shape, locked: false }\n}\n\nexport function newSimilarNote(note: Note) {\n    return newNote(\"HELLO\", note.color, 20, 20, note.width, note.height, note.shape)\n}\n\nexport function newText(\n    crdt: CrdtMode,\n    text: string = \"HELLO\",\n    x: number = 20,\n    y: number = 20,\n    width: number = 5,\n    height: number = 2,\n    z: number = 0,\n): Text {\n    return {\n        id: uuid.v4(),\n        type: \"text\",\n        text,\n        x,\n        y,\n        width,\n        height,\n        z,\n        color: \"none\",\n        locked: false,\n        crdt,\n    }\n}\n\nexport function newContainer(\n    crdt: CrdtMode,\n    x: number = 20,\n    y: number = 20,\n    width: number = 30,\n    height: number = 20,\n    z: number = 0,\n): Container {\n    return {\n        id: uuid.v4(),\n        type: \"container\",\n        text: \"Unnamed area\",\n        x,\n        y,\n        width,\n        height,\n        z,\n        color: \"white\",\n        locked: false,\n        crdt,\n    }\n}\n\nexport function newImage(\n    assetId: string,\n    x: number = 20,\n    y: number = 20,\n    width: number = 5,\n    height: number = 5,\n    z: number = 0,\n): Image {\n    return { id: uuid.v4(), type: \"image\", assetId, x, y, width, height, z, locked: false }\n}\n\nexport function newVideo(\n    assetId: string,\n    x: number = 20,\n    y: number = 20,\n    width: number = 5,\n    height: number = 5,\n    z: number = 0,\n): Video {\n    return { id: uuid.v4(), type: \"video\", assetId, x, y, width, height, z, locked: false }\n}\n\nexport const isBoardItemEvent = (a: AppEvent): a is BoardItemEvent =>\n    a.action.startsWith(\"item.\") ||\n    a.action.startsWith(\"connection.\") ||\n    a.action === \"board.rename\" ||\n    a.action === \"board.setAccessPolicy\"\n\nexport const isPersistableBoardItemEvent = (e: any): e is PersistableBoardItemEvent =>\n    isBoardItemEvent(e) && ![\"item.lock\", \"item.unlock\"].includes(e.action)\n\nexport const isBoardHistoryEntry = (e: AppEvent): e is BoardHistoryEntry =>\n    isPersistableBoardItemEvent(e) && !!(e as BoardHistoryEntry).user && !!(e as BoardHistoryEntry).timestamp\nexport const isLocalUIEvent = (e: AppEvent): e is LocalUIEvent => e.action.startsWith(\"ui.\")\nexport const isCursorMove = (e: AppEvent): e is CursorMove => e.action === \"cursor.move\"\nexport function isSameUser(a: EventUserInfo, b: EventUserInfo) {\n    return a.userType == b.userType && a.nickname == b.nickname\n}\n\nexport function isColoredItem(i: Item): i is ColoredItem {\n    return i.type === \"note\" || i.type === \"container\" || i.type === \"text\"\n}\n\nexport function isShapedItem(i: Item): i is ShapedItem {\n    return i.type === \"note\"\n}\n\nexport function isTextItem(i: Item): i is TextItem {\n    return i.type === \"note\" || i.type === \"text\" || i.type === \"container\"\n}\n\nexport function isNote(i: Item): i is Note {\n    return i.type === \"note\"\n}\n\nexport function isContainer(i: Item): i is Container {\n    return i.type === \"container\"\n}\n\nexport function isText(i: Item): i is Text {\n    return i.type === \"text\"\n}\n\nexport function isItem(i: Item | Point | Connection): i is Item {\n    return \"type\" in i\n}\n\nexport function getItemText(i: Item) {\n    if (isTextItem(i)) return i.text\n    return \"\"\n}\n\nexport function getItemBackground(i: Item) {\n    if (isColoredItem(i)) {\n        return i.color || \"white\" // Default for legacy containers\n    }\n    return \"none\"\n}\n\nexport function getItemShape(i: Item) {\n    return i.type === \"note\" && i.shape ? i.shape : \"rect\"\n}\n\ntype NamespacedEvent<Namespace extends string, T = AppEvent> = T extends { action: `${Namespace}.${string}` }\n    ? T\n    : never\n\nexport function actionNamespaceIs<Namespace extends string>(\n    ns: Namespace,\n    a: AppEvent,\n): a is NamespacedEvent<Namespace> {\n    return a.action.startsWith(ns + \".\")\n}\n\nexport function getItemIds(e: BoardHistoryEntry | PersistableBoardItemEvent): Id[] {\n    switch (e.action) {\n        case \"item.front\":\n        case \"item.delete\":\n        case \"item.font.decrease\":\n        case \"item.font.increase\":\n            return e.itemIds\n        case \"item.move\":\n            return e.items.map((i) => i.id)\n        case \"item.update\":\n            return e.items.map((i) => i.id)\n        case \"item.add\":\n            return e.items.map((i) => i.id)\n        case \"item.bootstrap\":\n            return Object.keys(e.items)\n        case \"board.rename\":\n        case \"board.setAccessPolicy\":\n        case \"connection.add\":\n        case \"connection.modify\":\n        case \"connection.delete\":\n            return []\n    }\n}\n\nexport const getItem = (boardOrItems: Board | Record<string, Item>) => (id: Id) => {\n    const item = findItem(boardOrItems)(id)\n    if (!item) throw Error(\"Item not found: \" + id)\n    return item\n}\n\nexport const getConnection = (b: Board) => (id: Id) => {\n    const conn = b.connections.find((c) => c.id === id)\n    if (!conn) throw Error(\"Connection not found: \" + id)\n    return conn\n}\n\nexport const findItem = (boardOrItems: Board | Record<string, Item>) => (id: Id): Item | null => {\n    const items = getItems(boardOrItems)\n    const item = items[id]\n    return item || null\n}\n\nexport const findConnection = (board: Board) => (id: Id) => {\n    const conn = board.connections.find((c) => c.id === id)\n    return conn || null\n}\n\nexport function findItemIdsRecursively(ids: Id[], board: Board): Set<Id> {\n    const recursiveIds = new Set<Id>()\n    const addIdRecursively = (id: Id) => {\n        recursiveIds.add(id)\n        Object.values(board.items).forEach((i) => i.containerId === id && addIdRecursively(i.id))\n    }\n    ids.forEach(addIdRecursively)\n    return recursiveIds\n}\n\nexport function findItemsRecursively(ids: Id[], board: Board): Item[] {\n    const recursiveIds = findItemIdsRecursively(ids, board)\n    return [...recursiveIds].map(getItem(board))\n}\n\nexport const isContainedBy = (boardOrItems: Board | Record<string, Item>, parentCandidate: Item) => (\n    i: Item,\n): boolean => {\n    if (!i.containerId) return false\n    if (i.containerId === parentCandidate!.id) return true\n    const itemsOnBoard = getItems(boardOrItems)\n    const parent = findItem(itemsOnBoard)(i.containerId)\n    if (i.containerId === i.id) throw Error(\"Self-contained\")\n    if (parent == i) throw Error(\"self parent\")\n    if (!parent) return false // Don't fail here, because when folding create+move, the action is run in an incomplete board context\n    return isContainedBy(boardOrItems, parentCandidate)(parent)\n}\n\nconst isBoard = (u: unknown): u is Board => typeof u === \"object\" && !!u && \"items\" in u\n\nconst getItems = (boardOrItems: Board | Record<string, Item>) =>\n    isBoard(boardOrItems) ? boardOrItems.items : boardOrItems\n\nexport function isBoardEmpty(board: Board) {\n    return board.connections.length === 0 && Object.values(board.items).length === 0\n}\n\nexport function getBoardAttributes(board: Board, userInfo?: EventUserInfo): BoardAttributes {\n    const accessPolicy = board.accessPolicy\n        ? userInfo && userInfo.userType === \"authenticated\"\n            ? board.accessPolicy\n            : { ...board.accessPolicy, allowList: [] } // Anonymize access policy for anonymous users\n        : undefined\n    return {\n        id: board.id,\n        name: board.name,\n        width: board.width,\n        height: board.height,\n        accessPolicy,\n    }\n}\n\nexport const BOARD_ITEM_BORDER_MARGIN = 0.5\n\nexport function checkBoardAccess(accessPolicy: BoardAccessPolicy | undefined, userInfo: SessionUserInfo): AccessLevel {\n    if (!accessPolicy) return \"read-write\"\n    let accessLevel: AccessLevel = accessPolicy.publicWrite\n        ? \"read-write\"\n        : accessPolicy.publicRead\n        ? \"read-only\"\n        : \"none\"\n    if (userInfo.userType === \"unidentified\" || userInfo.userType === \"system\") {\n        return accessLevel\n    }\n    const email = userInfo.email\n    const domain = userInfo.domain\n\n    const defaultAccess = \"read-write\"\n    for (let entry of accessPolicy.allowList) {\n        const nextLevel =\n            \"email\" in entry && entry.email === email\n                ? entry.access || defaultAccess\n                : \"domain\" in entry && domain === entry.domain\n                ? entry.access || defaultAccess\n                : \"none\"\n        accessLevel = combineAccessLevels(accessLevel, nextLevel)\n    }\n    return accessLevel\n}\n\nfunction combineAccessLevels(a: AccessLevel, b: AccessLevel): AccessLevel {\n    if (a === \"admin\" || b === \"admin\") return \"admin\"\n    if (a === \"read-write\" || b === \"read-write\") return \"read-write\"\n    if (a === \"read-only\" || b === \"read-only\") return \"read-only\"\n    return \"none\"\n}\n\nexport function canRead(a: AccessLevel) {\n    return a !== \"none\"\n}\n\nexport function canWrite(a: AccessLevel) {\n    return a === \"read-write\" || a === \"admin\"\n}\n\nexport type Align = \"TL\" | \"TC\" | \"TR\" | \"ML\" | \"MC\" | \"MR\" | \"BL\" | \"BC\" | \"BR\"\n\nexport function getAlign(item: TextItem) {\n    return item.align ?? (isNote(item) ? \"MC\" : \"TL\")\n}\n\nexport type HorizontalAlign = \"left\" | \"center\" | \"right\"\n\nexport function getHorizontalAlign(align: Align): HorizontalAlign {\n    switch (align) {\n        case \"TL\":\n        case \"ML\":\n        case \"BL\":\n            return \"left\"\n        case \"TC\":\n        case \"MC\":\n        case \"BC\":\n            return \"center\"\n        case \"TR\":\n        case \"MR\":\n        case \"BR\":\n            return \"right\"\n    }\n    console.log(\"Unknown align\", align)\n    return \"center\"\n}\n\nexport type VerticalAlign = \"top\" | \"middle\" | \"bottom\"\n\nexport function getVerticalAlign(align: Align): VerticalAlign {\n    switch (align) {\n        case \"TL\":\n        case \"TC\":\n        case \"TR\":\n            return \"top\"\n        case \"ML\":\n        case \"MC\":\n        case \"MR\":\n            return \"middle\"\n        case \"BL\":\n        case \"BC\":\n        case \"BR\":\n            return \"bottom\"\n    }\n    console.log(\"Unknown align\", align)\n    return \"middle\"\n}\n\nexport function setHorizontalAlign<I extends TextItem>(item: I, a: HorizontalAlign): ItemUpdate<I> {\n    const letter = a === \"left\" ? \"L\" : a === \"center\" ? \"C\" : \"R\"\n    const align = `${getAlign(item)[0]}${letter}`\n    return { id: item.id, align } as ItemUpdate<I>\n}\n\nexport function setVerticalAlign<I extends TextItem>(item: I, a: VerticalAlign): ItemUpdate<I> {\n    const letter = a === \"top\" ? \"T\" : a === \"middle\" ? \"M\" : \"B\"\n    const align = `${letter}${getAlign(item)[1]}`\n    return { id: item.id, align } as ItemUpdate<I>\n}\n"
  },
  {
    "path": "common/src/geometry.ts",
    "content": "import { Point } from \"./domain\"\nexport const origin = { x: 0, y: 0 }\nexport type Coordinates = { x: number; y: number }\nexport type Dimensions = { width: number; height: number }\nexport type Rect = { x: number; y: number; width: number; height: number }\nexport const ZERO_RECT = { x: 0, y: 0, height: 0, width: 0 }\nexport function add(a: Coordinates, b: Coordinates) {\n    return { x: a.x + b.x, y: a.y + b.y }\n}\n\nexport function subtract(a: Coordinates, b: Coordinates) {\n    return { x: a.x - b.x, y: a.y - b.y }\n}\n\nexport function negate(a: Coordinates) {\n    return { x: -a.x, y: -a.y }\n}\n\nexport function multiply(a: Coordinates, factor: number) {\n    return { x: a.x * factor, y: a.y * factor }\n}\n\nexport function overlaps(a: Rect, b: Rect) {\n    if (b.x >= a.x + a.width) return false\n    if (b.x + b.width <= a.x) return false\n    if (b.y >= a.y + a.height) return false\n    if (b.y + b.height <= a.y) return false\n    return true\n}\n\nexport function equalRect(a: Rect, b: Rect) {\n    return a.x == b.x && a.y == b.y && a.width == b.width && a.height == b.height\n}\n\nexport function distance(a: Coordinates, b: Coordinates) {\n    return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2))\n}\n\nexport function containedBy(a: Point, b: Rect): boolean\nexport function containedBy(a: Rect, b: Rect): boolean\nexport function containedBy(a: Rect | Point, b: Rect) {\n    if (\"width\" in a) {\n        return a.x > b.x && a.y > b.y && a.x + a.width < b.x + b.width && a.y + a.height < b.y + b.height\n    } else {\n        return a.x > b.x && a.y > b.y && a.x < b.x + b.width && a.y < b.y + b.height\n    }\n}\n\nexport function rectFromPoints(a: Coordinates, b: Coordinates) {\n    const x = Math.min(a.x, b.x)\n    const y = Math.min(a.y, b.y)\n\n    const width = Math.abs(a.x - b.x)\n    const height = Math.abs(a.y - b.y)\n\n    return { x, y, width, height }\n}\n\nexport function isRect(i: Point | Rect): i is Rect {\n    return \"width\" in i\n}\n\nexport function centerPoint(i: Point | Rect) {\n    if (isRect(i)) {\n        return {\n            x: i.x + i.width / 2,\n            y: i.y + i.height / 2,\n        }\n    }\n    return i\n}\n"
  },
  {
    "path": "common/src/migration.test.ts",
    "content": "import { describe, expect, it } from \"vitest\"\nimport { arrayToRecordById } from \"./arrays\"\nimport {\n    AddConnection,\n    AddItem,\n    Board,\n    Connection,\n    ConnectionEndPoint,\n    DeleteConnection,\n    DeleteItem,\n    ModifyConnection,\n    MoveItem,\n    defaultBoardSize,\n    exampleBoard,\n    newISOTimeStamp,\n} from \"./domain\"\nimport { migrateBoard, migrateEvent } from \"./migration\"\n\ndescribe(\"Migration\", () => {\n    describe(\"Migrate board\", () => {\n        it(\"Migrates boards correctly\", () => {\n            const containedNoteWithNoType = {\n                id: \"a\",\n                x: 1,\n                y: 1,\n                width: 1,\n                height: 1,\n                text: \"note a\",\n                color: \"yellow\",\n            }\n            const containedNote2 = {\n                type: \"note\",\n                id: \"b\",\n                x: 2,\n                y: 2,\n                width: 1,\n                height: 1,\n                text: \"note b\",\n                color: \"yellow\",\n            }\n            const unContainedNoteWithNoDimensions = {\n                type: \"note\",\n                id: \"c\",\n                x: 3,\n                y: 3,\n                text: \"note c\",\n                color: \"yellow\",\n            }\n            const oldFormContainerWithItemsAndNoText = {\n                type: \"container\",\n                id: \"d\",\n                x: 0,\n                y: 0,\n                width: 5,\n                height: 5,\n                items: [\"a\", \"b\"],\n            }\n\n            const legacyBoard: any = {\n                id: \"foo\",\n                name:\n                    \"board with no size, where containers have items property and items do not have containerId property\",\n                items: [\n                    containedNoteWithNoType,\n                    containedNote2,\n                    unContainedNoteWithNoDimensions,\n                    oldFormContainerWithItemsAndNoText,\n                ],\n            }\n\n            const board = migrateBoard(legacyBoard)\n\n            expect(board).toEqual({\n                ...legacyBoard,\n                ...defaultBoardSize,\n                connections: [],\n                items: arrayToRecordById([\n                    { ...containedNoteWithNoType, type: \"note\", containerId: \"d\", z: 0, locked: false },\n                    { ...containedNote2, containerId: \"d\", z: 0, locked: false },\n                    { ...unContainedNoteWithNoDimensions, width: 5, height: 5, z: 0, locked: false },\n                    { type: \"container\", id: \"d\", x: 0, y: 0, width: 5, height: 5, z: 0, text: \"\", locked: false },\n                ]),\n            })\n        })\n        it(\"Removes broken connections\", () => {\n            const borkenEndpoint: ConnectionEndPoint = { id: \"asdf\", side: \"bottom\" }\n\n            const b: Board = {\n                ...exampleBoard,\n                connections: [\n                    {\n                        from: borkenEndpoint,\n                        to: borkenEndpoint,\n                        id: \"asfdoi\",\n                        controlPoints: [],\n                        fromStyle: \"none\",\n                        toStyle: \"none\",\n                        pointStyle: \"none\",\n                        action: \"connect\",\n                        locked: false,\n                    },\n                ],\n            }\n\n            expect(migrateBoard(b)).toEqual(exampleBoard)\n        })\n\n        it(\"Sets connection end styles\", () => {\n            const b: Board = {\n                ...exampleBoard,\n                connections: [{ from: { x: 0, y: 0 }, to: { x: 0, y: 0 }, id: \"asfdoi\", controlPoints: [] } as any],\n            }\n\n            expect(migrateBoard(b)).toEqual({\n                ...exampleBoard,\n                connections: [\n                    {\n                        from: { x: 0, y: 0 },\n                        to: { x: 0, y: 0 },\n                        id: \"asfdoi\",\n                        controlPoints: [],\n                        fromStyle: \"black-dot\",\n                        toStyle: \"arrow\",\n                        pointStyle: \"black-dot\",\n                        action: \"connect\",\n                        locked: false,\n                    } as Connection,\n                ],\n            })\n        })\n    })\n    describe(\"Migrate event\", () => {\n        const headers = {\n            user: { userType: \"unidentified\", nickname: \"asdf\" },\n            timestamp: newISOTimeStamp(),\n            boardId: \"\",\n        }\n        it(\"connection.add\", () => {\n            const connection = { from: \"a\", to: \"b\", controlPoints: [], id: \"c\" }\n            expect(\n                (migrateEvent({\n                    ...headers,\n                    action: \"connection.add\",\n                    connection,\n                } as any) as AddConnection).connections,\n            ).toEqual([connection])\n            expect(\n                (migrateEvent({\n                    ...headers,\n                    action: \"connection.add\",\n                    connection: [connection],\n                } as any) as AddConnection).connections,\n            ).toEqual([connection])\n        })\n\n        it(\"connection.modify\", () => {\n            const connection = { from: \"a\", to: \"b\", controlPoints: [], id: \"c\" }\n            expect(\n                (migrateEvent({\n                    ...headers,\n                    action: \"connection.modify\",\n                    connection,\n                } as any) as ModifyConnection).connections,\n            ).toEqual([connection])\n            expect(\n                (migrateEvent({\n                    ...headers,\n                    action: \"connection.modify\",\n                    connection: [connection],\n                } as any) as ModifyConnection).connections,\n            ).toEqual([connection])\n        })\n\n        it(\"connection.delete\", () => {\n            const connectionId = \"c\"\n            expect(\n                (migrateEvent({\n                    ...headers,\n                    action: \"connection.delete\",\n                    connectionId,\n                } as any) as DeleteConnection).connectionIds,\n            ).toEqual([connectionId])\n            expect(\n                (migrateEvent({\n                    ...headers,\n                    action: \"connection.delete\",\n                    connectionId: [connectionId],\n                } as any) as DeleteConnection).connectionIds,\n            ).toEqual([connectionId])\n        })\n\n        it(\"item.move\", () => {\n            expect(\n                (migrateEvent({\n                    ...headers,\n                    action: \"item.move\",\n                    items: [],\n                } as any) as MoveItem).connections,\n            ).toEqual([])\n        })\n\n        it(\"item.delete\", () => {\n            expect(\n                (migrateEvent({\n                    ...headers,\n                    action: \"item.delete\",\n                    itemIds: [],\n                } as any) as DeleteItem).connectionIds,\n            ).toEqual([])\n        })\n\n        it(\"item.add\", () => {\n            expect(\n                (migrateEvent({\n                    ...headers,\n                    action: \"item.add\",\n                    items: [],\n                } as any) as AddItem).connections,\n            ).toEqual([])\n        })\n    })\n})\n"
  },
  {
    "path": "common/src/migration.ts",
    "content": "import { isArray } from \"lodash\"\nimport { arrayToRecordById, toArray } from \"./arrays\"\nimport { resolveEndpoint } from \"./connection-utils\"\nimport { Board, BoardHistoryEntry, Container, Connection, defaultBoardSize, Id, Item, Serial } from \"./domain\"\n\nexport function mkBootStrapEvent(boardId: Id, snapshot: Board, serial: Serial = 1) {\n    return {\n        action: \"item.bootstrap\",\n        boardId,\n        items: snapshot.items,\n        connections: snapshot.connections,\n        timestamp: new Date().toISOString(),\n        user: { nickname: \"admin\", userType: \"system\" },\n        serial,\n    } as BoardHistoryEntry\n}\n\nexport function migrateBoard(origBoard: Board) {\n    const board = { ...origBoard }\n    const items: Item[] = []\n    const width = Math.max(board.width || 0, defaultBoardSize.width)\n    const height = Math.max(board.height || 0, defaultBoardSize.height)\n    for (const item of Object.values(board.items)) {\n        if (items.find((i) => i.id === item.id)) {\n            console.warn(\"Duplicate item\", item, \"found on table\", board.name)\n        } else {\n            items.push(migrateItem(item, items, board.items))\n        }\n    }\n    if (board.accessPolicy) {\n        if (!board.accessPolicy.allowList.some((e) => e.access === \"admin\")) {\n            console.log(`No board admin for board ${board.id} -> mapping all read-write users as admins`)\n            board.accessPolicy.allowList = board.accessPolicy.allowList.map((e) => ({\n                ...e,\n                access: e.access === \"read-write\" ? \"admin\" : e.access,\n            }))\n        }\n    }\n\n    const connections = (board.connections ?? [])\n        .filter((c) => {\n            try {\n                resolveEndpoint(c.from, board)\n                resolveEndpoint(c.to, board)\n            } catch (e) {\n                console.error(`Error resolving connection ${JSON.stringify(c)}`)\n                return false\n            }\n            return true\n        })\n        .map(migrateConnection)\n\n    return { ...board, connections, width, height, items: arrayToRecordById(items) }\n}\n\nfunction migrateConnection(c: Connection): Connection {\n    c =\n        c.fromStyle && c.fromStyle !== (\"white-dot\" as any) && c.toStyle && c.pointStyle\n            ? c\n            : { ...c, fromStyle: \"black-dot\", toStyle: \"arrow\", pointStyle: \"black-dot\" }\n    c = c.action !== undefined ? c : { ...c, action: \"connect\" }\n    c = c.locked !== undefined ? c : { ...c, locked: false }\n    return c\n}\n\nfunction migrateItem(item: Item, migratedItems: Item[], boardItems: Record<string, Item>): Item {\n    const { width, height, z, type, locked, ...rest } = item\n\n    // Force type, width and height for all items\n    let fixedItem = {\n        type: type || \"note\",\n        width: width || 5,\n        height: height || 5,\n        z: z || 0,\n        locked: locked || false,\n        ...rest,\n    } as Item\n    if (fixedItem.type === \"text\") {\n        fixedItem.color ??= \"none\"\n    }\n    if (fixedItem.type === \"container\") {\n        let container = fixedItem as Container & { items?: string[] }\n        // Force container to have text\n        container.text = container.text || \"\"\n        // If container had items property, migrate each corresponding item to have containerId of that container instead\n        if (container.items) {\n            const ids = container.items\n            delete container.items\n            ids.forEach((i) => {\n                const containedItem = migratedItems.find((mi) => mi.id === i) || boardItems[i]\n                containedItem && (containedItem.containerId = container.id)\n            })\n        }\n    }\n\n    return fixedItem\n}\n\nexport function migrateEvent(event: BoardHistoryEntry): BoardHistoryEntry {\n    if (event.action === \"connection.add\") {\n        if (!isArray(event.connections)) {\n            return { ...event, connections: toArray((event as any).connection) }\n        }\n    } else if (event.action === \"connection.modify\") {\n        if (!isArray(event.connections)) {\n            return { ...event, connections: toArray((event as any).connection) }\n        }\n    } else if (event.action === \"connection.delete\") {\n        if (!isArray(event.connectionIds)) {\n            return { ...event, connectionIds: toArray((event as any).connectionId) }\n        }\n    } else if (event.action === \"item.move\") {\n        if (!event.connections) {\n            return { ...event, connections: [] }\n        }\n    } else if (event.action === \"item.delete\") {\n        if (!event.connectionIds) {\n            return { ...event, connectionIds: [] }\n        }\n    } else if (event.action === \"item.add\") {\n        if (!event.connections) {\n            return { ...event, connections: [] }\n        }\n    }\n    return event\n}\n"
  },
  {
    "path": "common/src/sets.ts",
    "content": "export function toggleInSet<T>(item: T, set: Set<T>) {\n    if (set.has(item)) {\n        return new Set([...set].filter((i) => i !== item))\n    }\n    return new Set([...set].concat(item))\n}\n\nexport function difference<A>(setA: Set<A>, setB: Set<A>) {\n    let _difference = new Set(setA)\n    for (let elem of setB) {\n        _difference.delete(elem)\n    }\n    return _difference\n}\n\nexport const emptySet = <A>() => new Set<A>()\n"
  },
  {
    "path": "common/src/sleep.ts",
    "content": "export function sleep(ms: number): Promise<void> {\n    return new Promise((resolve) => setTimeout(() => resolve(undefined), ms))\n}\n"
  },
  {
    "path": "common/src/vector2.ts",
    "content": "export type Vector2 = { x: number; y: number }\n\nexport function Vector2(x: number, y: number) {\n    return { x, y }\n}\n\nexport function getAngleRad(v: Vector2) {\n    const unit = withLength(v, 1)\n    return Math.atan2(unit.y, unit.x)\n}\n\nexport function getAngleDeg(v: Vector2) {\n    return radToDeg(getAngleRad(v))\n}\n\nexport function getLength(v: Vector2) {\n    return Math.sqrt(v.x * v.x + v.y * v.y)\n}\n\nexport function withLength(v: Vector2, newLength: number) {\n    return multiply(v, newLength / getLength(v))\n}\n\nexport function multiply(v: Vector2, multiplier: number) {\n    return Vector2(v.x * multiplier, v.y * multiplier)\n}\n\nexport function add(v: Vector2, other: Vector2) {\n    return Vector2(v.x + other.x, v.y + other.y)\n}\n\nexport function rotateRad(v: Vector2, radians: number) {\n    var length = getLength(v)\n    var currentRadians = getAngleRad(v)\n    var resultRadians = radians + currentRadians\n    var rotatedUnit = { x: Math.cos(resultRadians), y: Math.sin(resultRadians) }\n    return withLength(rotatedUnit, length)\n}\n\nexport function rotateDeg(v: Vector2, degrees: number) {\n    return rotateRad(v, degToRad(degrees))\n}\n\nexport function degToRad(degrees: number) {\n    return (degrees * 2 * Math.PI) / 360\n}\n\nexport function radToDeg(rad: number) {\n    return (rad * 360) / 2 / Math.PI\n}\n"
  },
  {
    "path": "cypress.json",
    "content": "{\n    \"pluginsFile\": false,\n    \"supportFile\": false,\n    \"fixturesFolder\": false,\n    \"retries\": 2\n}\n"
  },
  {
    "path": "docker-compose.yaml",
    "content": "version: \"3.1\"\n\nservices:\n    db:\n        image: postgres:12\n        restart: always\n        ports:\n            - 13338:5432\n        environment:\n            POSTGRES_USER: r-board\n            POSTGRES_PASSWORD: secret\n\n    keycloak-db:\n        image: postgres:12\n        restart: always\n        ports:\n            - 13339:5432\n        environment:\n            POSTGRES_USER: keycloak\n            POSTGRES_PASSWORD: secret\n        volumes:\n            - ./keycloak/keycloak-db.dump:/docker-entrypoint-initdb.d/keycloak-db.dump.sql\n    keycloak:\n        depends_on:\n            - keycloak-db\n        image: quay.io/keycloak/keycloak:22.0.5\n        command: start-dev --db postgres --db-url jdbc:postgresql://keycloak-db/keycloak --db-username keycloak --db-password secret\n        ports:\n            - 8080:8080\n        environment:\n            - KEYCLOAK_ADMIN=admin\n            - KEYCLOAK_ADMIN_PASSWORD=admin\n            - DATABASE_URL=postgres://keycloak:secreto@keycloak-db:5432/keycloak\n"
  },
  {
    "path": "frontend/.sassrc",
    "content": "{\n  \"includePaths\": [\"node_modules\"]\n}"
  },
  {
    "path": "frontend/esbuild.js",
    "content": "require(\"dotenv\").config()\n\nconst sass = require(\"sass\")\nconst path = require(\"path\")\nconst fs = require(\"fs\")\nconst esbuild = require(\"esbuild\")\nconst rimraf = require(\"rimraf\")\nconst chokidar = require(\"chokidar\")\n\nconst mode = process.argv[2]\n\nif (!mode) {\n    throw Error(\"Specify 'build' or 'watch' as argument\")\n}\nconst stubImportsPlugin = (paths) => {\n    return {\n        name: \"stub-imports\",\n        setup(build) {\n            const regex = new RegExp(`^(${paths.join(\"|\")})\\$`)\n            build.onResolve({ filter: regex }, (args) => ({\n                path: args.path,\n                namespace: \"stub-imports-namespace\",\n            }))\n            build.onLoad({ filter: /.*/, namespace: \"stub-imports-namespace\" }, () => ({\n                contents: \"{}\",\n                loader: \"json\",\n            }))\n        },\n    }\n}\n\nconst sassPlugin = {\n    name: \"sass\",\n    setup(build) {\n        build.onResolve({ filter: /(\\.svg|\\.png)$/ }, (args) => {\n            return {\n                path: path.resolve(CWD, \"src\", args.path),\n            }\n        })\n        build.onResolve({ filter: /\\.scss$/ }, (args) => {\n            return {\n                path: path.resolve(args.resolveDir, args.path),\n                namespace: \"sass\",\n            }\n        })\n        build.onLoad({ filter: /.*/, namespace: \"sass\" }, (args) => {\n            let compiled = sass.renderSync({ file: args.path })\n            return {\n                contents: compiled.css.toString(),\n                loader: \"css\",\n            }\n        })\n    },\n}\n\nconst CWD = process.cwd()\nconst DIST_FOLDER = path.resolve(CWD, \"dist\")\n\nconst envFallback = (envVar, fb = null) => (envVar ? `\"${envVar}\"` : `${fb}`)\n\nasync function build() {\n    if (fs.existsSync(DIST_FOLDER)) rimraf.sync(DIST_FOLDER)\n    const randomString = Math.random().toString(36).slice(2)\n    const outfile = path.resolve(CWD, `dist/bundle.${randomString}.js`)\n    const metafile = path.resolve(CWD, `dist/bundle.${randomString}.meta.json`)\n    const now = Date.now()\n    await esbuild.build({\n        entryPoints: [path.resolve(CWD, \"src\", \"index.tsx\")],\n        bundle: true,\n        minify: mode !== \"watch\",\n        outfile,\n        metafile,\n        sourcemap: true,\n        platform: \"browser\",\n        plugins: [sassPlugin, stubImportsPlugin([\"path\"])],\n        loader: { \".png\": \"file\", \".svg\": \"file\" },\n        define: {\n            \"process.env.NODE_ENV\": envFallback(process.env.NODE_ENV, `\"development\"`),\n        },\n    })\n\n    fs.writeFileSync(\n        path.resolve(CWD, \"dist/index.html\"),\n        fs\n            .readFileSync(path.resolve(CWD, \"index.tmpl.html\"), \"utf8\")\n            .replace(\"JAVASCRIPT_BUNDLE\", `/bundle.${randomString}.js`)\n            .replace(\"CSS_BUNDLE\", `/bundle.${randomString}.css`),\n    )\n    console.log(`Frontend build done, took ${Date.now() - now} ms`)\n}\n\nif (mode === \"build\") {\n    build().catch((e) => !console.error(e) && process.exit(1))\n} else if (mode === \"watch\") {\n    build()\n        .catch((e) => console.error(e))\n        .then(() => {\n            chokidar\n                .watch([path.resolve(CWD, \"src\"), path.resolve(CWD, \"../common/src\")], { ignoreInitial: true })\n                .on(\"all\", (...arg) => {\n                    build().catch((e) => console.error(e))\n                })\n        })\n} else {\n    throw Error(\"Unknown mode: \" + mode)\n}\n"
  },
  {
    "path": "frontend/index.tmpl.html",
    "content": "<html lang=\"en\">\n    <head>\n        <title>OurBoard</title>\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0\" />\n        <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" />\n        <link href=\"https://fonts.googleapis.com/css2?family=Raleway:wght@500&display=swap\" rel=\"stylesheet\" />\n        <link href=\"https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.core.css\" rel=\"stylesheet\" />\n        <script src=\"https://apis.google.com/js/api.js\"></script>\n        <script defer=\"defer\" src=\"JAVASCRIPT_BUNDLE\"></script>\n        <link href=\"CSS_BUNDLE\" rel=\"stylesheet\" />\n    </head>\n\n    <body>\n        <div id=\"root\"></div>\n    </body>\n</html>\n"
  },
  {
    "path": "frontend/package.json",
    "content": "{\n  \"name\": \"rboard-frontend\",\n  \"version\": \"1.0.0\",\n  \"main\": \"index.js\",\n  \"license\": \"MIT\",\n  \"dependencies\": {\n    \"@types/cookie\": \"^0.4.0\",\n    \"@types/email-validator\": \"^1.0.6\",\n    \"@types/js-cookie\": \"^3.0.6\",\n    \"@types/lodash\": \"^4.14.161\",\n    \"@types/md5\": \"^2.2.1\",\n    \"@types/path-to-regexp\": \"^1.7.0\",\n    \"@types/pretty-ms\": \"^5.0.1\",\n    \"@types/quill\": \"^2.0.14\",\n    \"@types/ramda\": \"^0.27.40\",\n    \"@types/sanitize-html\": \"^1.27.1\",\n    \"bezier-js\": \"^4.0.3\",\n    \"binpackingjs\": \"^3.0.2\",\n    \"cookie\": \"^0.4.1\",\n    \"email-validator\": \"^2.0.4\",\n    \"esbuild\": \"^0.8.57\",\n    \"fp-ts\": \"^2.9.5\",\n    \"harmaja\": \"^0.24\",\n    \"harmaja-router\": \"^0.3.3\",\n    \"io-ts\": \"^2.2.15\",\n    \"js-cookie\": \"^3.0.5\",\n    \"jsonwebtoken\": \"^8.5.1\",\n    \"jwt-decode\": \"^3.1.2\",\n    \"localforage\": \"^1.9.0\",\n    \"lodash\": \"^4.17.20\",\n    \"lonna\": \"^0.12.2\",\n    \"md5\": \"^2.3.0\",\n    \"path-to-regexp\": \"^6.2.0\",\n    \"pretty-ms\": \"^7.0.1\",\n    \"quill\": \"^1.3.7\",\n    \"quill-cursors\": \"^4.0.2\",\n    \"ramda\": \"^0.27.1\",\n    \"rimraf\": \"^3.0.2\",\n    \"sanitize-html\": \"^2.3.2\",\n    \"sass\": \"^1.32.8\",\n    \"uuid\": \"^8.3.0\",\n    \"y-indexeddb\": \"^9.0.12\",\n    \"y-quill\": \"^0.1.5\",\n    \"y-websocket\": \"^1.5.3\",\n    \"yjs\": \"^13.6.12\"\n  },\n  \"scripts\": {\n    \"build\": \"node esbuild.js build\",\n    \"watch\": \"node esbuild.js watch\",\n    \"tsc:watch\": \"tsc --watch --noEmit\"\n  },\n  \"devDependencies\": {\n    \"@types/jsonwebtoken\": \"^8.5.0\",\n    \"@types/uuid\": \"^8.3.0\",\n    \"chokidar\": \"^3.5.1\",\n    \"core-js\": \"^3.8.3\",\n    \"cssnano\": \"^4.1.10\",\n    \"dotenv\": \"^8.2.0\",\n    \"nodemon\": \"^2.0.4\",\n    \"npm-run-all\": \"^4.1.5\",\n    \"typescript\": \"^5.3\"\n  },\n  \"browserslist\": [\n    \"since 2017-06\"\n  ]\n}\n"
  },
  {
    "path": "frontend/src/app.scss",
    "content": "@import \"style/variables.scss\";\n@import \"style/global.scss\";\n@import \"style/dashboard.scss\";\n@import \"style/utils.scss\";\n@import \"style/board.scss\";\n@import \"style/tool-layer.scss\";\n@import \"style/header.scss\";\n@import \"style/modal.scss\";\n@import \"style/sharing-modal.scss\";\n@import \"style/user-info-modal.scss\";\n"
  },
  {
    "path": "frontend/src/board/BoardView.tsx",
    "content": "import * as H from \"harmaja\"\nimport { componentScope, h, ListView } from \"harmaja\"\nimport * as L from \"lonna\"\nimport {\n    Board,\n    canWrite,\n    findConnection,\n    findItem,\n    getConnection,\n    Id,\n    Image,\n    Item,\n    newNote,\n    Note,\n    Video,\n} from \"../../../common/src/domain\"\nimport { isFirefox } from \"../components/browser\"\nimport { ModalContainer } from \"../components/ModalContainer\"\nimport { onClickOutside } from \"../components/onClickOutside\"\nimport { isEmbedded } from \"../embedding\"\nimport { AssetStore } from \"../store/asset-store\"\nimport { BoardState, BoardStore, Dispatch } from \"../store/board-store\"\nimport { CursorsStore } from \"../store/cursors-store\"\nimport { UserSessionState } from \"../store/user-session-store\"\nimport { boardCoordinateHelper } from \"./board-coordinates\"\nimport { boardDragHandler } from \"./board-drag\"\nimport {\n    BoardFocus,\n    getSelectedItemIds,\n    getSelectedItem,\n    getSelectedItems,\n    noFocus,\n    getSelectedConnectionIds,\n} from \"./board-focus\"\nimport { boardScrollAndZoomHandler } from \"./board-scroll-and-zoom\"\nimport { BoardToolLayer } from \"./toolbars/BoardToolLayer\"\nimport { ConnectionsView } from \"./ConnectionsView\"\nimport { ContextMenuView } from \"./contextmenu/ContextMenuView\"\nimport { CursorsView } from \"./CursorsView\"\nimport * as G from \"../../../common/src/geometry\"\nimport { imageUploadHandler, imageDropHandler } from \"./image-upload\"\nimport { ImageView } from \"./ImageView\"\nimport { itemCreateHandler } from \"./item-create\"\nimport { cutCopyPasteHandler } from \"./item-cut-copy-paste\"\nimport { itemDeleteHandler } from \"./item-delete\"\nimport { itemDuplicateHandler } from \"./item-duplicate\"\nimport { itemMoveWithArrowKeysHandler } from \"./item-move-with-arrow-keys\"\nimport { itemSelectAllHandler } from \"./item-select-all\"\nimport { withCurrentContainer } from \"./item-setcontainer\"\nimport { itemUndoHandler } from \"./item-undo-redo\"\nimport { ItemView } from \"./ItemView\"\nimport { installKeyboardShortcut, plainKey } from \"./keyboard-shortcuts\"\nimport { RectangularDragSelection } from \"./RectangularDragSelection\"\nimport { SelectionBorder } from \"./SelectionBorder\"\nimport { synchronizeFocusWithServer } from \"./synchronize-focus-with-server\"\nimport { ToolController } from \"./tool-selection\"\nimport { BoardViewHeader } from \"./header/BoardViewHeader\"\nimport { VideoView } from \"./VideoView\"\nimport { startConnecting } from \"./item-connect\"\nimport { emptySet } from \"../../../common/src/sets\"\nimport { installZoomKeyboardShortcuts } from \"./zoom-shortcuts\"\nimport { itemHideContentsHandler } from \"./item-hide-contents\"\n\nconst emptyNote = newNote(\"\")\n\nexport const BoardView = ({\n    boardId,\n    cursors,\n    boardStore,\n    sessionState,\n    assets,\n    dispatch,\n}: {\n    boardId: string\n    cursors: CursorsStore\n    boardStore: BoardStore\n    sessionState: L.Property<UserSessionState>\n    assets: AssetStore\n    dispatch: Dispatch\n}) => {\n    const boardState = boardStore.state\n    const board = boardState.pipe(\n        L.map((s: BoardState) => s.board!),\n        L.filter((b: Board) => !!b, componentScope()),\n    )\n    const accessLevel = L.view(boardState, \"accessLevel\")\n    const locks = L.view(boardState, (s) => s.locks)\n    const sessionId = L.view(sessionState, (s) => s.sessionId)\n    const sessions = L.view(boardState, (s) => s.users)\n    const zoom = L.atom({ zoom: 1, quickZoom: 1 })\n\n    const containerElement = L.atom<HTMLElement | null>(null)\n    const scrollElement = L.atom<HTMLElement | null>(null)\n    const boardElement = L.atom<HTMLElement | null>(null)\n    const latestNoteId = L.atom<Id | null>(null)\n    const latestNote = L.view(latestNoteId, board, (id, b) => {\n        const note = id ? findItem(b)(id) : null\n        return (note as Note) || emptyNote\n    })\n    const latestConnectionId = L.atom<Id | null>(null)\n    const latestConnection = L.view(latestConnectionId, board, (id, b) => {\n        return id ? findConnection(b)(id) : null\n    })\n    const focus = synchronizeFocusWithServer(board, locks, sessionId, dispatch)\n    const coordinateHelper = boardCoordinateHelper(containerElement, scrollElement, boardElement, zoom)\n    const toolController = ToolController()\n    const tool = toolController.tool\n\n    let previousFocus: BoardFocus | null = null\n    focus.forEach((f) => {\n        const previousIDs = previousFocus && getSelectedItemIds(previousFocus)\n        const itemIds = [...getSelectedItemIds(f)].filter((id) => !previousIDs || !previousIDs.has(id))\n        previousFocus = f\n        if (itemIds.length > 0) {\n            dispatch({ action: \"item.front\", boardId: board.get().id, itemIds })\n            const item = getSelectedItem(board.get())(f)\n            if (item && item.type === \"note\") {\n                latestNoteId.set(item.id)\n            }\n        }\n        const connectionId = [...getSelectedConnectionIds(f)][0]\n        if (connectionId && getConnection(board.get())(connectionId)?.action === \"connect\") {\n            latestConnectionId.set(connectionId)\n        }\n    })\n\n    tool.pipe(L.changes).forEach((tool) => {\n        if (tool !== \"note\" && tool !== \"container\" && tool !== \"text\" && focus.get().status === \"adding\") {\n            focus.set(noFocus)\n        }\n    })\n\n    const doOnUnmount: Function[] = []\n\n    const itemsList = L.view(L.view(board, \"items\"), Object.values)\n\n    function onURL(assetId: string, url: string) {\n        itemsList.get().forEach((i) => {\n            if ((i.type === \"image\" || i.type === \"video\") && i.assetId === assetId && i.src != url) {\n                dispatch({ action: \"item.update\", boardId, items: [{ id: i.id, src: url }] })\n            }\n        })\n    }\n    const uploadImageFile = imageUploadHandler(assets, coordinateHelper, onAdd, onURL)\n\n    doOnUnmount.push(\n        cutCopyPasteHandler(board, boardStore.crdtStore, focus, coordinateHelper, dispatch, uploadImageFile),\n    )\n\n    const zoomControls = boardScrollAndZoomHandler(\n        board,\n        boardElement,\n        scrollElement,\n        zoom,\n        coordinateHelper,\n        toolController,\n    )\n    const { viewRect } = zoomControls\n\n    boardStore.eventsFromServer.forEach((e) => {\n        if (e.action === \"user.bringAllToMe\") {\n            console.log(`Following user ${e.nickname}`, e)\n            viewRect.set(e.viewRect)\n        }\n    })\n\n    imageDropHandler(boardElement, assets, focus, uploadImageFile)\n    itemCreateHandler(board, focus, latestNote, boardElement, onAdd)\n    itemDeleteHandler(boardId, dispatch, focus)\n    itemDuplicateHandler(board, boardStore.crdtStore, dispatch, focus)\n    itemHideContentsHandler(board, focus, dispatch)\n    itemMoveWithArrowKeysHandler(board, dispatch, focus)\n    itemUndoHandler(dispatch)\n    itemSelectAllHandler(board, focus)\n    installZoomKeyboardShortcuts(zoomControls)\n    installKeyboardShortcut(\n        (e) => e.key === \"Escape\",\n        () => {\n            toolController.useDefaultTool()\n            focus.set(noFocus)\n        },\n    )\n    installKeyboardShortcut(plainKey(\"c\"), () => toolController.tool.set(\"connect\"))\n    installKeyboardShortcut(plainKey(\"l\"), () => toolController.tool.set(\"line\"))\n    L.fromEvent<JSX.KeyboardEvent>(window, \"click\")\n        .pipe(L.applyScope(componentScope()))\n        .forEach((event) => {\n            if (!boardElement.get()!.contains(event.target as Node)) {\n                // Click outside => reset selection\n                focus.set(noFocus)\n            }\n        })\n\n    onClickOutside(boardElement, () => {\n        focus.set(noFocus)\n    })\n\n    function onClick(e: JSX.UIEvent) {\n        const f = focus.get()\n        if (f.status === \"connection-adding\") {\n            toolController.useDefaultTool()\n        } else if (f.status === \"adding\") {\n            onAdd(f.item)\n        } else {\n            if (e.target === boardElement.get()) {\n                if (tool.get() === \"connect\") {\n                    startConnecting(\n                        board,\n                        coordinateHelper,\n                        latestConnection,\n                        dispatch,\n                        toolController,\n                        focus,\n                        coordinateHelper.currentBoardCoordinates.get(),\n                    )\n                } else {\n                    focus.set(noFocus)\n                }\n            }\n        }\n    }\n\n    function onAdd(item: Item) {\n        toolController.useDefaultTool()\n        const point = coordinateHelper.currentBoardCoordinates.get()\n        const { x, y } = item.type !== \"container\" ? G.add(point, { x: -item.width / 2, y: -item.height / 2 }) : point\n        item = withCurrentContainer({ ...item, x, y }, board.get())\n\n        dispatch({ action: \"item.add\", boardId, items: [item], connections: [] })\n\n        if (item.type === \"note\" || item.type === \"text\") {\n            focus.set({ status: \"editing\", itemId: item.id })\n        } else {\n            focus.set({ status: \"selected\", itemIds: new Set([item.id]), connectionIds: emptySet() })\n        }\n    }\n\n    coordinateHelper.currentBoardCoordinates.pipe(L.throttle(30)).forEach((position) => {\n        dispatch({ action: \"cursor.move\", position, boardId })\n    })\n\n    const { selectionRect } = boardDragHandler({\n        ...{\n            board,\n            boardElem: boardElement,\n            coordinateHelper,\n            latestConnection,\n            focus,\n            toolController,\n            dispatch,\n        },\n    })\n\n    H.onUnmount(() => {\n        doOnUnmount.forEach((fn) => fn())\n    })\n\n    const boardAccessStatus = L.view(boardState, (s) => s.status)\n    const quickZoom = L.view(zoom, \"quickZoom\")\n    const mainZoom = L.view(zoom, \"zoom\")\n    const borderContainerStyle = L.combineTemplate({\n        width: L.view(board, quickZoom, (b) => b.width + \"em\"),\n        height: L.view(board, quickZoom, (b) => b.height + \"em\"),\n        fontSize: L.view(mainZoom, (z) => z + \"em\"),\n        transform: L.view(quickZoom, (z) => {\n            const percentTranslate = ((z - 1) / 2) * 100\n            return `translate(${percentTranslate}%, ${percentTranslate}%) scale(${z})`\n        }),\n        \"will-change\": \"transform, fontSize\",\n    })\n\n    const className = L.view(\n        boardAccessStatus,\n        (status) => `board-container ${isEmbedded() ? \"embedded\" : \"\"} ${status}`,\n    )\n\n    const items = L.view(L.view(board, \"items\"), Object.values, (items) => items.filter((i) => !i.hidden))\n    const selectedItems = L.view(board, focus, (b, f) => getSelectedItems(b)(f))\n    const modalContent = L.atom<any>(null)\n\n    L.interval(100, null, componentScope()).forEach(() => {\n        if (window.scrollY !== 0 || window.scrollX !== 0) {\n            if (focus.get().status !== \"editing\") {\n                // Reset scroll position when not editing. At least iOS seems to need this. It's vital that our scroll pos stays at origin.\n                window.scrollTo(0, 0)\n            }\n        }\n    })\n    return (\n        <div id=\"root\" className={className}>\n            <ModalContainer content={modalContent} />\n            <BoardViewHeader\n                {...{\n                    board,\n                    usersOnBoard: L.view(boardState, \"users\"),\n                    sessionState,\n                    dispatch,\n                    accessLevel,\n                    modalContent,\n                    eventsFromServer: boardStore.eventsFromServer,\n                    viewRect,\n                    online: L.view(boardStore.state, (s) => s.status === \"online\"),\n                    crdtStore: boardStore.crdtStore,\n                }}\n            />\n            <div className=\"content-container\" ref={containerElement.set}>\n                <div className=\"scroll-container\" ref={scrollElement.set}>\n                    <div className=\"border-container\" style={borderContainerStyle}>\n                        <div\n                            className={L.view(tool, (t) => \"board \" + t)}\n                            draggable={isFirefox ? L.view(focus, (f) => f.status !== \"editing\") : true}\n                            ref={boardElement.set}\n                            onClick={onClick}\n                            onTouchEnd={onClick}\n                        >\n                            <ListView observable={items} renderObservable={renderItem} getKey={(i) => i.id} />\n\n                            {L.view(tool, (t) =>\n                                t === \"connect\" ? null : (\n                                    <ListView\n                                        observable={selectedItems}\n                                        renderObservable={renderSelectionBorder}\n                                        getKey={(i) => i.id}\n                                    />\n                                ),\n                            )}\n                            <RectangularDragSelection {...{ rect: selectionRect }} />\n                            <CursorsView {...{ cursors, sessions, viewRect }} />\n                            <ContextMenuView\n                                {...{\n                                    latestNote,\n                                    dispatch,\n                                    board,\n                                    focus,\n                                    viewRect,\n                                }}\n                            />\n                            <ConnectionsView {...{ board, zoom, dispatch, focus, coordinateHelper }} />\n                        </div>\n                    </div>\n                </div>\n                <BoardToolLayer\n                    {...{\n                        board,\n                        accessLevel,\n                        boardStore,\n                        containerElement,\n                        coordinateHelper,\n                        dispatch,\n                        focus,\n                        latestNote,\n                        onAdd,\n                        toolController,\n                        sessionState,\n                        ...zoomControls,\n                    }}\n                />\n            </div>\n        </div>\n    )\n\n    function renderSelectionBorder(id: string, item: L.Property<Item>) {\n        return <SelectionBorder {...{ id, tool, item: item, coordinateHelper, board, focus, dispatch }} />\n    }\n\n    function renderItem(id: string, item: L.Property<Item>) {\n        const isLocked = L.combineTemplate({ locks, sessionId }).pipe(\n            L.map(({ locks, sessionId }) => !!locks[id] && locks[id] !== sessionId),\n        )\n\n        return L.view(L.view(item, \"type\"), (t) => {\n            switch (t) {\n                case \"container\":\n                case \"text\":\n                case \"note\":\n                    return (\n                        <ItemView\n                            {...{\n                                board,\n                                history,\n                                id,\n                                type: t,\n                                item: item as L.Property<Note>,\n                                isLocked,\n                                focus,\n                                coordinateHelper,\n                                latestConnection,\n                                dispatch,\n                                toolController,\n                                accessLevel,\n                                boardStore,\n                            }}\n                        />\n                    )\n                case \"image\":\n                    return (\n                        <ImageView\n                            {...{\n                                id,\n                                image: item as L.Property<Image>,\n                                assets,\n                                board,\n                                isLocked,\n                                focus,\n                                toolController,\n                                coordinateHelper,\n                                latestConnection,\n                                dispatch,\n                            }}\n                        />\n                    )\n                case \"video\":\n                    return (\n                        <VideoView\n                            {...{\n                                id,\n                                video: item as L.Property<Video>,\n                                assets,\n                                board,\n                                isLocked,\n                                focus,\n                                toolController,\n                                coordinateHelper,\n                                latestConnection,\n                                dispatch,\n                            }}\n                        />\n                    )\n                default:\n                    throw Error(\"Unsupported item: \" + t)\n            }\n        })\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/BoardViewMessage.tsx",
    "content": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board } from \"../../../common/src/domain\"\nimport { signIn } from \"../google-auth\"\nimport { BoardAccessStatus } from \"../store/board-store\"\nimport { UserSessionState } from \"../store/user-session-store\"\n\nexport const BoardViewMessage = ({\n    boardAccessStatus,\n    sessionState,\n    board,\n}: {\n    boardAccessStatus: L.Property<BoardAccessStatus>\n    sessionState: L.Property<UserSessionState>\n    board: L.Property<Board>\n}) => {\n    // TODO: login may be disabled due to Incognito mode or other reasons\n    return L.combine(boardAccessStatus, L.view(sessionState, \"status\"), (s: BoardAccessStatus, sessionStatus) => {\n        if (s === \"not-found\") {\n            return (\n                <div className=\"board-status-message\">\n                    <div>\n                        <p>Board not found. A typo, maybe?</p>\n                    </div>\n                </div>\n            )\n        }\n        if (s === \"denied-permanently\") {\n            return (\n                <div className=\"board-status-message\">\n                    <div>\n                        <p>\n                            Sorry, access denied. Click <a onClick={signIn}>here</a> to sign in with another account.\n                        </p>\n                    </div>\n                </div>\n            )\n        }\n        if (s === \"login-required\") {\n            if (sessionStatus === \"login-failed\") {\n                return (\n                    <div className=\"board-status-message\">\n                        <div>\n                            Something went wrong with logging in. Click <a onClick={signIn}>here</a> to try again.\n                        </div>\n                    </div>\n                )\n            }\n\n            return (\n                <div className=\"board-status-message\">\n                    <div>\n                        This board is for authorized users only. Click <a onClick={signIn}>here</a> to sign in.\n                    </div>\n                </div>\n            )\n        }\n        return null\n    })\n}\n"
  },
  {
    "path": "frontend/src/board/CollaborativeTextView.tsx",
    "content": "import { componentScope, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport Quill from \"quill\"\nimport QuillCursors from \"quill-cursors\"\nimport { QuillBinding } from \"y-quill\"\nimport {\n    AccessLevel,\n    Board,\n    LocalUIEvent,\n    TextItem,\n    canWrite,\n    getAlign,\n    getHorizontalAlign,\n    getItemBackground,\n} from \"../../../common/src/domain\"\nimport { CRDTStore } from \"../store/crdt-store\"\nimport { getAlignItems } from \"./ItemView\"\nimport { BoardFocus } from \"./board-focus\"\nimport { contrastingColor } from \"./contrasting-color\"\nimport { preventDoubleClick } from \"./double-click\"\nimport PasteLinkOverText from \"./quillPasteLinkOverText\"\nimport ClickableLink from \"./quillClickableLink\"\n\nQuill.register(\"modules/cursors\", QuillCursors)\nQuill.register(\"modules/pasteLinkOverText\", PasteLinkOverText)\nQuill.register(ClickableLink)\n\ninterface CollaborativeTextViewProps {\n    item: L.Property<TextItem>\n    board: L.Property<Board>\n    id: string\n    accessLevel: L.Property<AccessLevel>\n    focus: L.Atom<BoardFocus>\n    itemFocus: L.Property<\"none\" | \"selected\" | \"dragging\" | \"editing\">\n    crdtStore: CRDTStore\n    isLocked: L.Property<boolean>\n    uiEvents: L.EventStream<LocalUIEvent>\n}\nexport function CollaborativeTextView({\n    id,\n    item,\n    board,\n    accessLevel,\n    focus,\n    itemFocus,\n    isLocked,\n    crdtStore,\n    uiEvents,\n}: CollaborativeTextViewProps) {\n    const fontSize = L.view(item, (i) => `${i.fontSize ? i.fontSize : 1}em`)\n    const color = L.view(item, getItemBackground, contrastingColor)\n\n    const quillEditor = L.atom<Quill | null>(null)\n\n    accessLevel.applyScope(componentScope()).forEach((al) => {\n        quillEditor.get()?.enable(canWrite(al))\n    })\n\n    function initQuill(el: HTMLElement) {\n        const quill = new Quill(el, {\n            modules: {\n                cursors: true,\n                toolbar: false,\n                pasteLinkOverText: true,\n                history: {\n                    userOnly: true, // Local undo shouldn't undo changes from remote users\n                },\n            },\n            theme: \"snow\",\n            readOnly: !canWrite(accessLevel.get()),\n        })\n        const crdt = crdtStore.getBoardCrdt(board.get().id)\n        const ytext = crdt.getField(id, \"text\")\n        new QuillBinding(ytext, quill, crdt.awareness)\n        quillEditor.set(quill)\n    }\n\n    const editingThis = L.view(itemFocus, (f) => f === \"editing\")\n\n    editingThis.forEach((e) => {\n        const q = quillEditor.get()\n        if (q) {\n            if (e) {\n                const multipleLines =\n                    q\n                        .getText()\n                        .split(\"\\n\")\n                        .filter((x) => x).length > 1\n                if (!multipleLines) {\n                    // For one-liners, select the whole text on double click\n                    q.setSelection(0, 1000000)\n                }\n            } else {\n                // Clear text selecting when not editing\n                q.setSelection(null as any)\n            }\n        }\n    })\n\n    function handleClick() {\n        if (itemFocus.get() === \"selected\") {\n            focus.set({ status: \"editing\", itemId: id })\n        }\n    }\n\n    const pointerEvents = L.view(itemFocus, isLocked, (f, l) =>\n        f === \"editing\" || f === \"selected\" || l ? \"auto\" : \"none\",\n    )\n    const hAlign = L.view(item, getAlign, getHorizontalAlign).applyScope(componentScope())\n    hAlign.onChange((align) => {\n        quillEditor.get()?.formatText(0, 10000000, \"align\", align === \"left\" ? \"\" : align)\n    })\n\n    uiEvents.applyScope(componentScope()).forEach((e) => {\n        if (e.action === \"ui.text.format\" && e.itemIds.includes(id)) {\n            const quill = quillEditor.get()\n            const selection = quill?.getSelection()\n            const format = selection && quill?.getFormat(selection)\n            const newValue = !(format && format[e.format])\n            quill?.format(e.format, newValue)\n        }\n    })\n\n    let touchMoves = 0\n\n    return (\n        <div\n            className=\"quill-wrapper text\"\n            onKeyUp={(e) => {\n                e.stopPropagation()\n                if (e.key === \"Escape\") {\n                    focus.set({ status: \"selected\", itemIds: new Set([id]), connectionIds: new Set() })\n                }\n            }}\n            onKeyDown={(e) => {\n                e.stopPropagation()\n            }}\n            onKeyPress={(e) => {\n                e.stopPropagation()\n            }}\n            onDoubleClick={(e) => {\n                e.stopPropagation()\n                quillEditor.get()?.focus()\n            }}\n            onTouchStart={(e) => {\n                preventDoubleClick(e)\n                touchMoves = 0\n            }}\n            onTouchMove={() => touchMoves++}\n            onTouchEnd={() => {\n                if (touchMoves === 0) {\n                    // This is a way to detect a tap (vs swipe)\n                    quillEditor.get()?.focus()\n                }\n            }}\n            onClick={handleClick}\n            style={L.combineTemplate({ alignItems: L.view(item, getAlignItems) })}\n        >\n            <div\n                className=\"quill-editor\"\n                style={L.combineTemplate({ fontSize, color, pointerEvents })}\n                ref={initQuill}\n            />\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/ConnectionsView.tsx",
    "content": "import { Fragment, h, ListView } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { findAttachmentLocation, resolveItemEndpoint } from \"../../../common/src/connection-utils\"\nimport {\n    AttachmentLocation,\n    Board,\n    ConnectionEndPoint,\n    ConnectionEndStyle,\n    isDirectedItemEndPoint,\n    Item,\n    Point,\n} from \"../../../common/src/domain\"\nimport { Dispatch } from \"../store/board-store\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { BoardFocus, getSelectedConnectionIds } from \"./board-focus\"\nimport { existingConnectionHandler } from \"./item-connect\"\nimport { Z_CONNECTIONS } from \"./zIndices\"\n\nexport const ConnectionsView = ({\n    board,\n    dispatch,\n    zoom,\n    coordinateHelper,\n    focus,\n}: {\n    board: L.Property<Board>\n    dispatch: Dispatch\n    zoom: L.Property<BoardZoom>\n    coordinateHelper: BoardCoordinateHelper\n    focus: L.Atom<BoardFocus>\n}) => {\n    // Item position might change but connection doesn't -- need to rerender connections anyway\n    // Connection objects normally only hold the ID to the \"from\" and \"to\" items\n    // This populates the actual object in place of the ID\n\n    function determineAttachmenLocation(\n        e: ConnectionEndPoint,\n        control: Point,\n        is: Record<string, Item>,\n    ): AttachmentLocation {\n        if (isDirectedItemEndPoint(e)) {\n            return findAttachmentLocation(resolveItemEndpoint(e, is), e.side)\n        }\n        const fromItem: Point = resolveEndpoint(e, is)\n        // Support legacy routing (side not fixed)\n        return findNearestAttachmentLocationForConnectionNode(fromItem, control)\n    }\n    const connectionsWithItemsPopulated = L.view(\n        L.view(board, (b) => ({ is: b.items, cs: b.connections })),\n        focus,\n        L.view(zoom, \"zoom\"),\n        ({ is, cs }, f, z) => {\n            return cs\n                .filter((c) => !c.hidden)\n                .map((c) => {\n                    const fromItem: Point = resolveEndpoint(c.from, is)\n                    const toItemOrPoint = resolveEndpoint(c.to, is)\n                    const firstControlPoint = c.controlPoints[0] || fromItem\n                    const lastControlPoint = c.controlPoints[c.controlPoints.length - 1] || toItemOrPoint\n                    return {\n                        ...c,\n                        from: determineAttachmenLocation(c.from, firstControlPoint, is),\n                        to: determineAttachmenLocation(c.to, lastControlPoint, is),\n                        selected: getSelectedConnectionIds(f).has(c.id),\n                    }\n                })\n        },\n    )\n\n    // We want to render round draggable nodes at the end of edges (paths),\n    // But SVG elements are not draggable by default, so get a flat list of\n    // nodes and render them as regular HTML elements\n    const connectionNodes = L.view(connectionsWithItemsPopulated, (cs) =>\n        cs.flatMap((c) => [\n            { id: c.id, type: \"from\" as const, node: c.from, selected: c.selected, style: c.fromStyle },\n            { id: c.id, type: \"to\" as const, node: c.to, selected: c.selected, style: c.toStyle },\n            ...c.controlPoints.map((cp) => ({\n                id: c.id,\n                type: \"control\" as const,\n                node: { point: cp, side: \"none\" as const },\n                selected: c.selected,\n                style: c.pointStyle,\n            })),\n        ]),\n    )\n\n    const svgElementStyle = L.combineTemplate({\n        width: L.view(board, (b) => b.width + \"em\"),\n        height: L.view(board, (b) => b.height + \"em\"),\n        position: \"absolute\",\n        top: 0,\n        left: 0,\n        pointerEvents: \"none\",\n        zIndex: Z_CONNECTIONS,\n    })\n\n    return (\n        <>\n            <ListView<ConnectionNodeProps, string>\n                observable={connectionNodes}\n                renderObservable={ConnectionNode}\n                getKey={(c) => c.id + c.type}\n            />\n            <svg className=\"connections\" style={svgElementStyle}>\n                <ListView\n                    observable={connectionsWithItemsPopulated}\n                    renderObservable={(key, conn) => {\n                        const curve = L.combine(\n                            L.view(conn, \"from\"),\n                            L.view(conn, \"to\"),\n                            L.view(conn, \"controlPoints\"),\n                            (from, to, cps) => {\n                                return quadraticCurveSVGPath(\n                                    {\n                                        x: coordinateHelper.emToBoardPx(from.point.x),\n                                        y: coordinateHelper.emToBoardPx(from.point.y),\n                                    },\n                                    {\n                                        x: coordinateHelper.emToBoardPx(to.point.x),\n                                        y: coordinateHelper.emToBoardPx(to.point.y),\n                                    },\n                                    cps.map((cp) => ({\n                                        x: coordinateHelper.emToBoardPx(cp.x),\n                                        y: coordinateHelper.emToBoardPx(cp.y),\n                                    })),\n                                )\n                            },\n                        )\n                        return (\n                            <g>\n                                <path\n                                    className={L.view(conn, (c) => (c.selected ? \"connection selected\" : \"connection\"))}\n                                    d={curve}\n                                ></path>\n                            </g>\n                        )\n                    }}\n                    getKey={(c) => c.id}\n                />\n            </svg>\n        </>\n    )\n\n    type ConnectionNodeProps = {\n        id: string\n        node: AttachmentLocation\n        type: \"to\" | \"from\" | \"control\"\n        style: ConnectionEndStyle\n        selected: boolean\n    }\n    function ConnectionNode(key: string, cNode: L.Property<ConnectionNodeProps>) {\n        function onRef(el: HTMLDivElement) {\n            const { id, type } = cNode.get()\n            existingConnectionHandler(el, id, type, coordinateHelper, board, dispatch)\n        }\n\n        const id = L.view(cNode, (cn) => `connection-${cn.id}-${cn.type}`)\n\n        const angle = L.view(cNode, (cn) => {\n            if (cn.style !== \"arrow\" || cn.type === \"control\") return null\n            const conn = connectionsWithItemsPopulated.get().find((c) => c.id === cn.id)\n            if (!conn) {\n                return null\n            }\n            const [thisEnd, otherEnd] = cn.type === \"from\" ? [conn.from, conn.to] : [conn.to, conn.from]\n            const bez = bezierCurveFromPoints(\n                otherEnd.point,\n                getControlPoint(otherEnd.point, thisEnd.point, conn.controlPoints),\n                thisEnd.point,\n            )\n            const derivative = bez.derivative(1) // tangent vector at the very end of the curve\n            const angleInDegrees =\n                ((Math.atan2(derivative.y, derivative.x) - Math.atan2(0, Math.abs(derivative.x))) * 180) / Math.PI\n            return Math.round(angleInDegrees)\n        })\n\n        const wrapperStyle = L.view(cNode, (cn) => ({\n            top: `${cn.node.point.y}em`,\n            left: `${cn.node.point.x}em`,\n            zIndex: Z_CONNECTIONS + 1,\n        }))\n\n        const nodeStyle = L.view(angle, (ang) => ({\n            transform: ang !== null ? `rotate(${ang}deg)` : undefined,\n        }))\n\n        const selectThisConnection = (e: JSX.MouseEvent) => {\n            const id = cNode.get().id\n            const f = focus.get()\n            if (e.shiftKey && f.status === \"selected\") {\n                focus.set({ ...f, connectionIds: toggleInSet(id, f.connectionIds) })\n            } else {\n                focus.set({ status: \"selected\", connectionIds: new Set([id]), itemIds: emptySet() })\n            }\n        }\n\n        return (\n            <div\n                ref={onRef}\n                draggable={true}\n                onClick={selectThisConnection}\n                onDragStart={selectThisConnection}\n                style={wrapperStyle}\n                className=\"connection-node-grabber-helper\"\n            >\n                <div\n                    id={id}\n                    className={L.view(cNode, (cn) => {\n                        let cls = `connection-node ${cn.type}-node ${cn.style}-style `\n\n                        if (cn.selected) cls += \"highlight \"\n\n                        cls += cn.node.side === \"none\" ? \"unattached\" : \"attached\"\n\n                        return cls\n                    })}\n                    style={nodeStyle}\n                ></div>\n            </div>\n        )\n    }\n}\n\n// @ts-ignore\nimport { Bezier } from \"bezier-js\"\nimport { findNearestAttachmentLocationForConnectionNode, resolveEndpoint } from \"../../../common/src/connection-utils\"\nimport { emptySet, toggleInSet } from \"../../../common/src/sets\"\nimport { BoardZoom } from \"./board-scroll-and-zoom\"\n\nfunction quadraticCurveSVGPath(from: Point, to: Point, controlPoints: Point[]) {\n    if (!controlPoints.length) {\n        // fallback if no control points: straight line\n        const midPoint = getControlPoint(from, to, controlPoints)\n        return \"M\" + from.x + \" \" + from.y + \" Q \" + midPoint.x + \" \" + midPoint.y + \" \" + to.x + \" \" + to.y\n    } else {\n        const peakPointOfCurve = controlPoints[0]\n        const bez = bezierCurveFromPoints(from, peakPointOfCurve, to)\n        return bez\n            .getLUT()\n            .reduce(\n                (acc: string, p: Point, i: number) =>\n                    i === 0 ? (acc += `M ${p.x} ${p.y}`) : (acc += `L ${p.x} ${p.y}`),\n                \"\",\n            )\n    }\n}\n\nfunction getControlPoint(from: Point, to: Point, controlPoints: Point[]) {\n    if (controlPoints.length > 0) return controlPoints[0]\n    // fallback if no control points: midpoint\n    return { x: (to.x + from.x) * 0.5, y: (to.y + from.y) * 0.5 }\n}\n\nfunction bezierCurveFromPoints(from: Point, middle: Point, to: Point): any {\n    return Bezier.quadraticFromPoints(from, middle, to)\n}\n"
  },
  {
    "path": "frontend/src/board/CursorsView.tsx",
    "content": "import { componentScope, h, ListView } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { UserCursorPosition, UserSessionInfo } from \"../../../common/src/domain\"\nimport { CursorsStore } from \"../store/cursors-store\"\nimport { BoardZoom } from \"./board-scroll-and-zoom\"\nimport { Rect } from \"../../../common/src/geometry\"\nimport _ from \"lodash\"\n\nexport const CursorsView = ({\n    sessions,\n    cursors,\n    viewRect,\n}: {\n    cursors: CursorsStore\n    sessions: L.Property<UserSessionInfo[]>\n    viewRect: L.Property<Rect>\n}) => {\n    const transitionFromCursorDelay = cursors.cursorDelay.pipe(\n        L.changes,\n        L.throttle(2000, componentScope()),\n        L.map((d) => {\n            const t = (Math.min(d, 1000) / 1000).toFixed(1)\n            return `all ${t}s, top ${t}s`\n        }),\n    )\n    const transitionFromZoom = viewRect.pipe(\n        L.changes,\n        L.map(() => \"none\"),\n    )\n    const transition = L.merge(transitionFromCursorDelay, transitionFromZoom).pipe(\n        L.toProperty(\"none\", componentScope()),\n    )\n\n    const scope = componentScope()\n\n    return (\n        <ListView<UserCursorPosition, string>\n            observable={cursors.cursors}\n            renderObservable={(sessionId: string, pos_: L.Property<UserCursorPosition>) => {\n                const pos = pos_.pipe(L.skipDuplicates(_.isEqual), L.applyScope(scope))\n                const changes = pos.pipe(L.changes)\n                const stale = L.merge(\n                    changes.pipe(\n                        L.debounce(1000),\n                        L.map(() => true),\n                    ),\n                    changes.pipe(L.map(() => false)),\n                ).pipe(L.toProperty(false, scope))\n                const className = L.view(stale, (s) => (s ? \"cursor stale\" : \"cursor\"))\n                const style = L.view(pos, transition, viewRect, (p, t, vr) => {\n                    const x = _.clamp(p.x, vr.x, vr.x + vr.width - 1)\n                    const y = _.clamp(p.y, vr.y, vr.y + vr.height - 1)\n                    return {\n                        transition: t,\n                        left: x + \"em\",\n                        top: y + \"em\",\n                    }\n                })\n                const userInfo = L.view(sessions, (sessions) => {\n                    const session = sessions.find((s) => s.sessionId === sessionId)\n                    return {\n                        name: session ? session.nickname : null,\n                        picture: session && session.userType === \"authenticated\" ? <img src={session.picture} /> : null,\n                    }\n                })\n\n                return (\n                    <span className={className} style={style}>\n                        <span className=\"arrow\" />\n                        <span className=\"userInfo\">\n                            {L.view(userInfo, \"picture\")}\n                            <span className=\"text\">{L.view(userInfo, \"name\")}</span>\n                        </span>\n                    </span>\n                )\n            }}\n            getKey={(c: UserCursorPosition) => c.sessionId}\n        />\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/DragBorder.tsx",
    "content": "import { h, Fragment } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, Connection } from \"../../../common/src/domain\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { BoardFocus } from \"./board-focus\"\nimport { Dispatch } from \"../store/board-store\"\nimport { itemDragToMove } from \"./item-dragmove\"\nimport { Tool, ToolController } from \"./tool-selection\"\n\ntype Position = \"left\" | \"right\" | \"top\" | \"bottom\"\n\nexport const DragBorder = ({\n    id,\n    board,\n    coordinateHelper,\n    latestConnection,\n    focus,\n    toolController,\n    dispatch,\n}: {\n    id: string\n    coordinateHelper: BoardCoordinateHelper\n    latestConnection: L.Property<Connection | null>\n    focus: L.Atom<BoardFocus>\n    board: L.Property<Board>\n    toolController: ToolController\n    dispatch: Dispatch\n}) => {\n    return (\n        <>\n            <DragHandle {...{ position: \"left\" }} />\n            <DragHandle {...{ position: \"right\" }} />\n            <DragHandle {...{ position: \"top\" }} />\n            <DragHandle {...{ position: \"bottom\" }} />\n        </>\n    )\n\n    function DragHandle({ position }: { position: Position }) {\n        const ref = (e: HTMLElement) =>\n            itemDragToMove(id, board, focus, toolController, coordinateHelper, latestConnection, dispatch, false)(e)\n\n        return <span ref={ref} draggable={true} className={`edge-drag ${position}`} />\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/ImageView.tsx",
    "content": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, Connection, Image } from \"../../../common/src/domain\"\nimport { AssetStore } from \"../store/asset-store\"\nimport { Dispatch } from \"../store/board-store\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { BoardFocus } from \"./board-focus\"\nimport { itemDragToMove } from \"./item-dragmove\"\nimport { itemSelectionHandler } from \"./item-selection\"\nimport { ToolController } from \"./tool-selection\"\nimport { itemZIndex } from \"./zIndices\"\n\nexport const ImageView = ({\n    id,\n    image,\n    assets,\n    board,\n    isLocked,\n    focus,\n    toolController,\n    coordinateHelper,\n    latestConnection,\n    dispatch,\n}: {\n    board: L.Property<Board>\n    id: string\n    image: L.Property<Image>\n    isLocked: L.Property<boolean>\n    focus: L.Atom<BoardFocus>\n    toolController: ToolController\n    coordinateHelper: BoardCoordinateHelper\n    latestConnection: L.Property<Connection | null>\n    dispatch: Dispatch\n    assets: AssetStore\n}) => {\n    const { selected, onClick, onTouchStart } = itemSelectionHandler(\n        id,\n        \"image\",\n        focus,\n        toolController,\n        board,\n        coordinateHelper,\n        latestConnection,\n        dispatch,\n    )\n    const tool = toolController.tool\n    return (\n        <span\n            className=\"image\"\n            onClick={onClick}\n            onTouchStart={onTouchStart}\n            ref={\n                itemDragToMove(\n                    id,\n                    board,\n                    focus,\n                    toolController,\n                    coordinateHelper,\n                    latestConnection,\n                    dispatch,\n                    false,\n                ) as any\n            }\n            style={L.view(\n                image,\n                (p: Image) =>\n                    ({\n                        top: 0,\n                        left: 0,\n                        transform: `translate(${p.x}em, ${p.y}em)`,\n                        height: p.height + \"em\",\n                        width: p.width + \"em\",\n                        zIndex: itemZIndex(p),\n                        position: \"absolute\",\n                    } as any),\n            )}\n        >\n            <img loading=\"lazy\" src={L.view(image, (i) => assets.getAsset(i.assetId, i.src))} />\n            {L.view(isLocked, (l) => l && <span className=\"lock\">🔒</span>)}\n        </span>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/ItemView.tsx",
    "content": "import { componentScope, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport {\n    AccessLevel,\n    Board,\n    Connection,\n    getAlign,\n    getHorizontalAlign,\n    getItemBackground,\n    getItemShape,\n    getVerticalAlign,\n    isContainer,\n    isLocalUIEvent,\n    isTextItem,\n    Item,\n    ItemType,\n    TextItem,\n} from \"../../../common/src/domain\"\nimport { BoardStore, Dispatch } from \"../store/board-store\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { BoardFocus } from \"./board-focus\"\nimport { CollaborativeTextView } from \"./CollaborativeTextView\"\nimport { DragBorder } from \"./DragBorder\"\nimport { itemDragToMove } from \"./item-dragmove\"\nimport { itemSelectionHandler } from \"./item-selection\"\nimport { TextView } from \"./TextView\"\nimport { ToolController } from \"./tool-selection\"\nimport { itemZIndex } from \"./zIndices\"\nimport { VisibilityOffIcon } from \"../components/Icons\"\n\nexport const ItemView = ({\n    board,\n    accessLevel,\n    id,\n    type,\n    item,\n    isLocked,\n    focus,\n    coordinateHelper,\n    latestConnection,\n    dispatch,\n    toolController,\n    boardStore,\n}: {\n    board: L.Property<Board>\n    accessLevel: L.Property<AccessLevel>\n    id: string\n    type: ItemType\n    item: L.Property<Item>\n    isLocked: L.Property<boolean>\n    focus: L.Atom<BoardFocus>\n    coordinateHelper: BoardCoordinateHelper\n    latestConnection: L.Property<Connection | null>\n    dispatch: Dispatch\n    toolController: ToolController\n    boardStore: BoardStore\n}) => {\n    const element = L.atom<HTMLElement | null>(null)\n\n    const ref = (el: HTMLElement) => {\n        itemDragToMove(\n            id,\n            board,\n            focus,\n            toolController,\n            coordinateHelper,\n            latestConnection,\n            dispatch,\n            type === \"container\",\n        )(el)\n        element.set(el)\n    }\n\n    const { itemFocus, selected, onClick, onTouchStart } = itemSelectionHandler(\n        id,\n        type,\n        focus,\n        toolController,\n        board,\n        coordinateHelper,\n        latestConnection,\n        dispatch,\n    )\n\n    function itemPadding(i: Item) {\n        if (i.type != \"note\") return undefined\n\n        const shape = getItemShape(i)\n        return shape == \"diamond\"\n            ? `${i.width / 4}em`\n            : shape == \"round\"\n            ? `${i.width / 8}em`\n            : shape == \"square\" || shape == \"rect\"\n            ? `${(i.fontSize || 1) / 3}em`\n            : undefined\n    }\n    const shape = L.view(item, getItemShape)\n    const itemNow = item.get()\n\n    return (\n        <span\n            title={L.view(isLocked, (l) => (l ? \"Item is selected by another user\" : \"\"))}\n            ref={ref}\n            data-itemid={id}\n            draggable={L.view(itemFocus, (f) => f !== \"editing\")}\n            onClick={onClick}\n            onTouchStart={onTouchStart}\n            className={L.view(\n                selected,\n                L.view(item, getItemBackground),\n                isLocked,\n                (s, b, l) =>\n                    `${type} ${\"color-\" + b.replace(\"#\", \"\").toLowerCase()} ${s ? \"selected\" : \"\"} ${\n                        l ? \"locked\" : \"\"\n                    }`,\n            )}\n            style={L.view(item, (i) => {\n                return {\n                    top: 0,\n                    left: 0,\n                    height: i.height + \"em\",\n                    width: i.width + \"em\",\n                    transform: `translate(${i.x}em, ${i.y}em)`,\n                    zIndex: itemZIndex(i),\n                    position: \"absolute\",\n                    padding: itemPadding(i),\n                    justifyContent: getJustifyContent(i),\n                    alignItems: getAlignItems(i),\n                    textAlign: getTextAlign(i),\n                }\n            })}\n        >\n            <span\n                className={L.view(shape, (s) => \"shape \" + s)}\n                style={L.view(item, (i) => {\n                    return {\n                        background: getItemBackground(i),\n                    }\n                })}\n            />\n\n            {isTextItem(itemNow) && itemNow.crdt ? (\n                <CollaborativeTextView\n                    item={item as L.Property<TextItem>}\n                    board={board}\n                    id={id}\n                    accessLevel={accessLevel}\n                    focus={focus}\n                    itemFocus={itemFocus}\n                    crdtStore={boardStore.crdtStore}\n                    isLocked={isLocked}\n                    uiEvents={boardStore.events.pipe(L.filter(isLocalUIEvent), L.applyScope(componentScope()))}\n                />\n            ) : (\n                <TextView\n                    id={id}\n                    item={item as L.Property<TextItem>}\n                    dispatch={dispatch}\n                    board={board}\n                    toolController={toolController}\n                    accessLevel={accessLevel}\n                    focus={focus}\n                    itemFocus={itemFocus}\n                    coordinateHelper={coordinateHelper}\n                    element={element}\n                />\n            )}\n\n            {L.view(\n                item,\n                (i) => isContainer(i) && i.contentsHidden,\n                (hidden) =>\n                    (hidden && (\n                        <div className=\"hidden-contents-indicator\">\n                            <VisibilityOffIcon />\n                        </div>\n                    )) ??\n                    null,\n            )}\n\n            {type === \"container\" && (\n                <DragBorder {...{ id, board, toolController, coordinateHelper, latestConnection, focus, dispatch }} />\n            )}\n        </span>\n    )\n}\n\nexport function getJustifyContent(item: Item) {\n    if (isTextItem(item)) {\n        switch (getHorizontalAlign(getAlign(item))) {\n            case \"left\":\n                return \"flex-start\"\n            case \"center\":\n                return \"center\"\n            case \"right\":\n                return \"flex-end\"\n        }\n    }\n    return null\n}\n\nexport function getAlignItems(item: Item) {\n    if (isTextItem(item)) {\n        switch (getVerticalAlign(getAlign(item))) {\n            case \"top\":\n                return \"flex-start\"\n            case \"middle\":\n                return \"center\"\n            case \"bottom\":\n                return \"flex-end\"\n        }\n    }\n    return null\n}\n\nfunction getTextAlign(item: Item) {\n    if (isTextItem(item)) {\n        return getHorizontalAlign(getAlign(item))\n    }\n    return null\n}\n"
  },
  {
    "path": "frontend/src/board/RectangularDragSelection.tsx",
    "content": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Rect } from \"../../../common/src/geometry\"\n\nexport const RectangularDragSelection = ({ rect }: { rect: L.Property<Rect | null> }) => {\n    return L.view(\n        rect,\n        (r) =>\n            r && (\n                <span\n                    className=\"rectangular-selection\"\n                    style={{\n                        left: r.x + \"em\",\n                        top: r.y + \"em\",\n                        width: r.width + \"em\",\n                        height: r.height + \"em\",\n                    }}\n                />\n            ),\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/SaveAsTemplate.tsx",
    "content": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board } from \"../../../common/src/domain\"\n\nexport const SaveAsTemplate = ({ board }: { board: L.Property<Board | undefined> }) => {\n    const currSavedBoard = L.atom<Board | null>(null)\n\n    function handleLocalTemplateSave() {\n        const b = board.get()\n        if (!b) return\n        const saved = localStorage.getItem(\"rboard_templates\")\n        const templates = saved ? (JSON.parse(saved) as Record<string, Board>) : {}\n\n        templates[b.name] = b\n        localStorage.setItem(\"rboard_templates\", JSON.stringify(templates))\n        currSavedBoard.set(b)\n    }\n\n    const changed = L.combineTemplate({\n        curr: currSavedBoard,\n        next: board,\n    }).pipe(L.map((c) => c.curr !== c.next))\n    return (\n        <li\n            className={L.view(changed, (c) => (c ? \"\" : \"disabled\"))}\n            data-test=\"palette-save-as-template\"\n            onClick={() => handleLocalTemplateSave()}\n        >\n            Save as template\n        </li>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/SelectionBorder.tsx",
    "content": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { Board, Container } from \"../../../common/src/domain\"\nimport { BoardFocus } from \"./board-focus\"\nimport { onBoardItemDrag } from \"./item-drag\"\nimport { Dispatch } from \"../store/board-store\"\nimport { canMove } from \"./board-permissions\"\n\ntype Horizontal = \"left\" | \"right\"\ntype Vertical = \"top\" | \"bottom\"\nconst borderOffset = 0.25\n\nexport const SelectionBorder = ({\n    id,\n    board,\n    coordinateHelper,\n    focus,\n    dispatch,\n}: {\n    id: string\n    coordinateHelper: BoardCoordinateHelper\n    focus: L.Atom<BoardFocus>\n    board: L.Property<Board>\n    dispatch: Dispatch\n}) => {\n    const item = L.view(board, (b) => b.items[id])\n    const style = L.view(item, (i) => {\n        return {\n            top: -borderOffset + \"rem\",\n            left: -borderOffset + \"rem\",\n            height: `calc(${i.height}em + 2 * ${borderOffset}rem)`,\n            width: `calc(${i.width}em + 2 * ${borderOffset}rem)`,\n            transform: `translate(${i.x}em, ${i.y}em)`,\n        }\n    })\n\n    return L.view(\n        item,\n        (i) => !i.hidden && canMove(i),\n        (m) =>\n            m ? (\n                <span className=\"selection-control\" style={style}>\n                    <span className=\"corner-resize-drag top left\"></span>\n                    <DragCorner {...{ horizontal: \"left\", vertical: \"top\" }} />\n                    <DragCorner {...{ horizontal: \"left\", vertical: \"bottom\" }} />\n                    <DragCorner {...{ horizontal: \"right\", vertical: \"top\" }} />\n                    <DragCorner {...{ horizontal: \"right\", vertical: \"bottom\" }} />\n                </span>\n            ) : null,\n    )\n\n    function DragCorner({ vertical, horizontal }: { vertical: Vertical; horizontal: Horizontal }) {\n        const ref = (e: HTMLElement) =>\n            onBoardItemDrag(\n                e,\n                id,\n                board,\n                focus,\n                coordinateHelper,\n                false,\n                (b, startPos, items, connections, xDiff, yDiff) => {\n                    const updatedItems = items.map(({ current, dragStartPosition }) => {\n                        const maintainAspectRatio =\n                            current.type === \"image\" || (current.type === \"note\" && current.shape !== \"rect\")\n                        if (maintainAspectRatio) {\n                            let minDiff = Math.min(Math.abs(xDiff), Math.abs(yDiff))\n                            if (minDiff < 0.1) {\n                                xDiff = 0\n                                yDiff = 0\n                            } else {\n                                const aspectRatio = dragStartPosition.width / dragStartPosition.height\n                                const invert =\n                                    (horizontal == \"left\" && vertical == \"bottom\") ||\n                                    (horizontal == \"right\" && vertical == \"top\")\n                                const factor = invert ? -1 : 1\n\n                                if (Math.abs(xDiff) == minDiff) {\n                                    // x is the smaller adjustment, use that as basis\n                                    yDiff = (minDiff / aspectRatio) * factor * sign(xDiff)\n                                } else {\n                                    xDiff = minDiff * aspectRatio * factor * sign(yDiff)\n                                }\n                            }\n                        }\n\n                        const x = horizontal === \"left\" ? dragStartPosition.x + xDiff : dragStartPosition.x\n                        const y = vertical === \"top\" ? dragStartPosition.y + yDiff : dragStartPosition.y\n                        const width = Math.max(\n                            0.5,\n                            horizontal === \"left\" ? dragStartPosition.width - xDiff : dragStartPosition.width + xDiff,\n                        )\n\n                        const height = Math.max(\n                            0.5,\n                            vertical === \"top\" ? dragStartPosition.height - yDiff : dragStartPosition.height + yDiff,\n                        )\n                        const updatedItem = {\n                            id: current.id,\n                            x,\n                            y,\n                            width,\n                            height,\n                        }\n                        return updatedItem\n                    })\n\n                    dispatch({ action: \"item.update\", boardId: b.id, items: updatedItems })\n\n                    function sign(x: number) {\n                        return x / Math.abs(x)\n                    }\n                },\n            )\n\n        return <span ref={ref} draggable={true} className={`corner-resize-drag ${horizontal} ${vertical}`} />\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/TextView.tsx",
    "content": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { AccessLevel, Board, canWrite, getItemBackground, TextItem } from \"../../../common/src/domain\"\nimport { emptySet } from \"../../../common/src/sets\"\nimport { HTMLEditableSpan } from \"../components/HTMLEditableSpan\"\nimport { Dispatch } from \"../store/board-store\"\nimport { autoFontSize } from \"./autoFontSize\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { BoardFocus, getSelectedItemIds } from \"./board-focus\"\nimport { contrastingColor } from \"./contrasting-color\"\nimport { ToolController } from \"./tool-selection\"\n\ninterface TextViewProps {\n    id: string\n    item: L.Property<TextItem>\n    dispatch: Dispatch\n    board: L.Property<Board>\n    toolController: ToolController\n    accessLevel: L.Property<AccessLevel>\n    focus: L.Atom<BoardFocus>\n    itemFocus: L.Property<\"none\" | \"selected\" | \"dragging\" | \"editing\">\n    coordinateHelper: BoardCoordinateHelper\n    element: L.Property<HTMLElement | null>\n}\n\nexport function TextView({\n    id,\n    item,\n    dispatch,\n    board,\n    toolController,\n    focus,\n    coordinateHelper,\n    itemFocus,\n    accessLevel,\n    element,\n}: TextViewProps) {\n    const textAtom = L.atom(L.view(item, \"text\"), (text) =>\n        dispatch({ action: \"item.update\", boardId: board.get().id, items: [{ id, text }] }),\n    )\n    const showCoords = false\n    const focused = L.view(focus, (f) => getSelectedItemIds(f).has(id))\n\n    const setEditing = (e: boolean) => {\n        if (toolController.tool.get() === \"connect\") return // Don't switch to editing in middle of connecting\n        dispatch({ action: \"item.front\", boardId: board.get().id, itemIds: [id] })\n        focus.set(\n            e\n                ? { status: \"editing\", itemId: id }\n                : { status: \"selected\", itemIds: new Set([id]), connectionIds: emptySet() },\n        )\n    }\n    const color = L.view(item, getItemBackground, contrastingColor)\n    const fontSize = autoFontSize(\n        item,\n        L.view(item, (i) => (i.fontSize ? i.fontSize : 1)),\n        L.view(item, \"text\"),\n        focused,\n        coordinateHelper,\n        element,\n    )\n    return (\n        <span\n            className=\"text\"\n            onDoubleClick={(e) => e.stopPropagation()}\n            style={L.combineTemplate({ fontSize, color })}\n        >\n            <HTMLEditableSpan\n                {...{\n                    value: textAtom,\n                    editingThis: L.atom(\n                        L.view(itemFocus, (f) => f === \"editing\"),\n                        setEditing,\n                    ),\n                    editable: L.view(accessLevel, canWrite),\n                }}\n            />\n            {showCoords && <small>{L.view(item, (p) => Math.floor(p.x) + \", \" + Math.floor(p.y))}</small>}\n        </span>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/VideoView.tsx",
    "content": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { Board, Connection, Image, Video } from \"../../../common/src/domain\"\nimport { BoardFocus } from \"./board-focus\"\nimport { AssetStore } from \"../store/asset-store\"\nimport { itemDragToMove } from \"./item-dragmove\"\nimport { itemSelectionHandler } from \"./item-selection\"\nimport { Dispatch } from \"../store/board-store\"\nimport { Tool, ToolController } from \"./tool-selection\"\nimport { DragBorder } from \"./DragBorder\"\nimport { itemZIndex } from \"./zIndices\"\n\nexport const VideoView = ({\n    id,\n    video,\n    assets,\n    board,\n    isLocked,\n    focus,\n    toolController,\n    coordinateHelper,\n    latestConnection,\n    dispatch,\n}: {\n    board: L.Property<Board>\n    id: string\n    video: L.Property<Video>\n    isLocked: L.Property<boolean>\n    focus: L.Atom<BoardFocus>\n    toolController: ToolController\n    coordinateHelper: BoardCoordinateHelper\n    latestConnection: L.Property<Connection | null>\n    dispatch: Dispatch\n    assets: AssetStore\n}) => {\n    const { selected, onClick, onTouchStart } = itemSelectionHandler(\n        id,\n        \"video\",\n        focus,\n        toolController,\n        board,\n        coordinateHelper,\n        latestConnection,\n        dispatch,\n    )\n    const tool = toolController.tool\n    return (\n        <span\n            className=\"video\"\n            onClick={onClick}\n            onTouchStart={onTouchStart}\n            ref={\n                itemDragToMove(\n                    id,\n                    board,\n                    focus,\n                    toolController,\n                    coordinateHelper,\n                    latestConnection,\n                    dispatch,\n                    false,\n                ) as any\n            }\n            style={L.view(\n                video,\n                (p: Video) =>\n                    ({\n                        top: 0,\n                        left: 0,\n                        transform: `translate(${p.x}em, ${p.y}em)`,\n                        height: p.height + \"em\",\n                        width: p.width + \"em\",\n                        zIndex: itemZIndex(p),\n                        position: \"absolute\",\n                    } as any),\n            )}\n        >\n            <video id=\"video\" controls={true} preload=\"none\">\n                <source id=\"mp4\" src={L.view(video, (i) => assets.getAsset(i.assetId, i.src))} type=\"video/mp4\" />\n                <p>Your user agent does not support the HTML5 Video element.</p>\n            </video>\n            {L.view(isLocked, (l) => l && <span className=\"lock\">🔒</span>)}\n            <DragBorder {...{ id, board, toolController, coordinateHelper, latestConnection, focus, dispatch }} />\n        </span>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/autoFontSize.ts",
    "content": "import { isUndefined } from \"lodash\"\nimport * as L from \"lonna\"\nimport { getItemShape, Item, TextItem } from \"../../../common/src/domain\"\nimport { toPlainText } from \"../components/sanitizeHTML\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { Dimensions } from \"../../../common/src/geometry\"\n\nexport type AutoFontSizeOptions = {\n    maxFontSize: number\n    maxLines: number\n    hideIfNoFit: boolean\n    minFontSize: number\n    widthTarget: number\n    heightTarget: number\n}\n\nfunction getElementFont(e: HTMLElement | null) {\n    if (!e) return \"10px arial\"\n    const { fontFamily, fontSize } = getComputedStyle(e)\n    return `${fontSize} ${fontFamily}` // Firefox returns these properties separately, so can't just use computedStyle.font\n}\n\nconst defaultOptions = {\n    maxFontSize: Number.MAX_VALUE,\n    minFontSize: 0,\n    maxLines: Number.MAX_VALUE,\n    hideIfNoFit: false,\n    widthTarget: 0.65, // TODO: something fishy here, why these number need to be so low?\n    heightTarget: 0.6,\n}\n\nexport function autoFontSize(\n    item: L.Property<TextItem>,\n    fontSize: L.Property<number>,\n    text: L.Property<string>,\n    focused: L.Property<boolean>,\n    coordinateHelper: BoardCoordinateHelper,\n    element: L.Property<HTMLElement | null>,\n    options: Partial<AutoFontSizeOptions> = {},\n): L.Property<string> {\n    let fullOptions = { ...defaultOptions, ...options }\n\n    return L.view(\n        L.view(item, \"type\"),\n        L.view(item, getItemShape),\n        L.view(item, \"width\"),\n        L.view(item, \"height\"),\n        fontSize,\n        text,\n        (t, shape, w, h, fs, text) => {\n            if (t !== \"note\") return fs + \"em\"\n\n            const width = shape === \"round\" ? Math.sqrt(w ** 2 / 2) : shape === \"diamond\" ? w / 2 : w\n            const height = shape === \"round\" ? Math.sqrt(h ** 2 / 2) : shape === \"diamond\" ? h / 2 : h\n\n            const referenceFont = getElementFont(element.get())\n            const plainText = toPlainText(text)\n            const split = plainText.split(/\\s/)\n            const words = split\n                .map((s) => s.trim())\n                .filter((s) => s)\n                .map((s) => getTextDimensions(s, referenceFont))\n            const spaceCharSize = getTextDimensions(\"\", referenceFont)\n            const widthTarget = coordinateHelper.emToPagePx(width) * fullOptions.widthTarget\n            const heightTarget = coordinateHelper.emToPagePx(height) * fullOptions.heightTarget\n\n            const maxWidth = widthTarget\n            const lineSpacingEm = 0.4\n\n            let lowerBound = Math.max(0, fullOptions.minFontSize)\n            let upperBound = Math.min(10, fullOptions.maxFontSize)\n            let sizeEm = Math.min(1, upperBound)\n            let fit = false\n            if (words.length > 0) {\n                let iterations = 1\n                while (iterations < 10) {\n                    // Limited binary search\n                    const fitInfo = tryFit(sizeEm)\n                    const fitFactor = fitInfo.fitFactor\n\n                    //if (f) console.log(text, \"Try size\", sizeEm, \"Total lines\", fitInfo.lines.length, \"V-Fit\", fitInfo.heightFitFactor, \"H-fit\", fitInfo.widthFitFactor, \"limited by\", fitFactor === fitInfo.heightFitFactor ? \"height\" : \"width\")\n                    if (!fit && fitFactor <= 1) fit = true\n                    if (lowerBound >= upperBound) break\n                    if (fitFactor < 0.95) {\n                        // too small\n                        lowerBound = sizeEm\n                        sizeEm = (sizeEm + upperBound) / 2\n                    } else if (fitFactor > 1) {\n                        // too big\n                        upperBound = sizeEm\n                        sizeEm = (sizeEm + lowerBound) / 2\n                    } else {\n                        // Good enough\n                        break\n                    }\n                    iterations++\n                }\n            }\n\n            if (!fit && fullOptions.hideIfNoFit) return \"0\"\n\n            return Math.min(sizeEm, fullOptions.maxFontSize) * fs + \"em\"\n\n            // Try to fit text using given font size. Return fit factor (text size / max size)\n            function tryFit(sizeEm: number) {\n                let index = 0\n                let lines: Dimensions[] = []\n                let maxWordWidth = 0\n                let lineWidth = 0\n\n                while (true) {\n                    // loop through lines\n                    let nextWord = words[index]\n                    let nextWordWidth = nextWord.width * sizeEm\n                    maxWordWidth = Math.max(nextWordWidth, maxWordWidth)\n                    let nextWordWidthWithSpacing =\n                        (lineWidth == 0 ? nextWord.width : nextWord.width + spaceCharSize.width) * sizeEm\n                    let fitFactor = (lineWidth + nextWordWidthWithSpacing) / maxWidth\n                    if (fitFactor > 1) {\n                        // no more words for this line\n                        if (lines.length >= fullOptions.maxLines) {\n                            return { lines: [], fitFactor, widthFitFactor: fitFactor, heightFitFactor: 0 }\n                        } else if (lineWidth === 0) {\n                            //if (f) console.log(\"couldn't fit a single word, return factor based on width\")\n                            return { lines: [], fitFactor, widthFitFactor: fitFactor, heightFitFactor: 0 }\n                        } else {\n                            lines.push({ width: lineWidth, height: nextWord.height * sizeEm })\n                            lineWidth = 0\n                        }\n                    } else {\n                        // add this word on the line\n                        lineWidth = lineWidth + nextWordWidthWithSpacing\n                        if (++index >= words.length) {\n                            //if (f) console.log(\"All words added\", words)\n                            lines.push({ width: lineWidth, height: nextWord.height * sizeEm })\n                            lineWidth = 0\n                            break\n                        }\n                    }\n                }\n                // At this point the text was horizontally fit. Return fit factor based on height\n                const totalHeight =\n                    lines.reduce((h, l) => h + l.height, 0) + (lines.length - 1) * lineSpacingEm * sizeEm\n                const heightFitFactor = totalHeight / heightTarget\n                const widthFitFactor = maxWordWidth / widthTarget\n                const fitFactor = Math.max(heightFitFactor, widthFitFactor)\n                return { lines, fitFactor, heightFitFactor, widthFitFactor, totalHeight, lineCount: lines.length }\n            }\n        },\n    )\n}\n\nexport function getTextDimensions(text: string, font: string): Dimensions {\n    // if given, use cached canvas for better performance\n    // else, create new canvas\n    var gtw: any = getTextDimensions\n    var canvas: HTMLCanvasElement = gtw.canvas || (gtw.canvas = document.createElement(\"canvas\"))\n    var context = canvas.getContext(\"2d\")!\n    context.font = font\n    var metrics = context.measureText(text)\n    const height = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent\n    const width = metrics.width\n\n    return { height, width }\n}\n"
  },
  {
    "path": "frontend/src/board/board-coordinates.ts",
    "content": "import { componentScope } from \"harmaja\"\nimport * as _ from \"lodash\"\nimport * as L from \"lonna\"\nimport { add, Coordinates, origin, subtract } from \"../../../common/src/geometry\"\nimport { BoardZoom } from \"./board-scroll-and-zoom\"\nimport { onSingleTouch } from \"./touchScreen\"\n\nconst newCoordinates = (x: number, y: number): Coordinates => {\n    return { x, y }\n}\n\n// HTML client coordinates: relative to viewport\nexport type PageCoordinates = Coordinates\n// Board coordinates used in the domain objects: in em unit, from board top left corner.\nexport type BoardCoordinates = Coordinates\n\nexport type BoardCoordinateHelper = ReturnType<typeof boardCoordinateHelper>\n\nexport function boardCoordinateHelper(\n    containerElem: L.Property<HTMLElement | null>,\n    scrollElem: L.Property<HTMLElement | null>,\n    boardElem: L.Property<HTMLElement | null>,\n    zoom: L.Property<BoardZoom>,\n) {\n    const quickZoom = L.view(zoom, \"quickZoom\")\n\n    function pxToEm(px: number) {\n        return px / baseFontSize() / quickZoom.get()\n    }\n\n    function emToPagePx(em: number) {\n        return em * baseFontSize() * quickZoom.get()\n    }\n\n    function emToBoardPx(em: number) {\n        return em * baseFontSize()\n    }\n\n    function baseFontSize() {\n        const e = boardElem.get()\n        return e ? parseFloat(getComputedStyle(e).fontSize) : 10\n    }\n\n    function coordDiff(a: Coordinates, b: Coordinates) {\n        return newCoordinates(a.x - b.x, a.y - b.y)\n    }\n\n    function getBoardAbsolutePosition() {\n        const b = boardElem.get()\n        return b ? offset(b) : origin\n    }\n\n    function offset(el: HTMLElement): Coordinates {\n        let o = { x: el.offsetLeft, y: el.offsetTop }\n\n        if (el.parentElement) {\n            return add(o, offset(el.parentElement))\n        }\n        return o\n    }\n\n    function pageToPixelCoordinates(pp: PageCoordinates) {\n        return subtract(pp, getBoardAbsolutePosition())\n    }\n\n    function pageToBoardCoordinates(pageCoords: PageCoordinates): Coordinates {\n        const pixelCoords = pageToPixelCoordinates(pageCoords)\n        const scrollEl = scrollElem.get()\n        if (scrollEl === null) {\n            return origin // Not the smartest move\n        }\n\n        // Use offsetLeft/offsetTop instead of getBoundingClientRect for getting board position\n        // because drag-to-scroll uses CSS translate while dragging and we don't want that to affect the calculation.\n\n        return newCoordinates(pxToEm(pixelCoords.x + scrollEl.scrollLeft), pxToEm(pixelCoords.y + scrollEl.scrollTop))\n    }\n\n    // Page coordinates of mouse pointer\n    let currentPageCoordinates = L.atom(origin)\n\n    // Position on the viewport, with relation to the top-left corner of the board area.\n    // If board is scrolled to top left corner, the top-left corner of actual board area (excluding borders) will be at (0, 0)\n    let currentBoardViewPortCoordinates = L.view(currentPageCoordinates, (pp) =>\n        subtract(pp, getBoardAbsolutePosition()),\n    )\n\n    function pageCoordDiffToThisPoint(coords: PageCoordinates) {\n        return coordDiff(currentPageCoordinates.get(), coords)\n    }\n\n    L.view(boardElem, containerElem, (b, c) => [b, c]).forEach(([elem, container]) => {\n        if (!elem || !container) {\n            return\n        }\n\n        elem.addEventListener(\n            \"gesturestart\",\n            _.throttle(\n                (e) => {\n                    currentPageCoordinates.set({ x: e.pageX, y: e.pageY })\n                },\n                16,\n                { leading: true, trailing: true },\n            ),\n        )\n        elem.addEventListener(\n            \"dragover\",\n            _.throttle(\n                (e) => {\n                    currentPageCoordinates.set({ x: e.pageX, y: e.pageY })\n                    e.preventDefault() // To disable Safari slow animation\n                },\n                16,\n                { leading: true, trailing: true },\n            ),\n        )\n        container.addEventListener(\n            \"mousemove\",\n            _.throttle(\n                (e) => {\n                    currentPageCoordinates.set({ x: e.pageX, y: e.pageY })\n                },\n                16,\n                { leading: true, trailing: true },\n            ),\n        )\n        container.addEventListener(\"touchstart\", (e) => {\n            onSingleTouch(e, (touch) => currentPageCoordinates.set({ x: touch.pageX, y: touch.pageY }))\n        })\n        container.addEventListener(\"touchmove\", (e) => {\n            onSingleTouch(e, (touch) => currentPageCoordinates.set({ x: touch.pageX, y: touch.pageY }))\n        })\n    })\n\n    const scrollEvent = scrollElem.pipe(\n        L.changes,\n        L.flatMapLatest((el) => L.fromEvent(el, \"scroll\"), componentScope()),\n    )\n    const updateEvent = L.merge(scrollEvent, L.changes(zoom), L.changes(currentPageCoordinates))\n\n    // Mouse position in board coordinates\n    const currentBoardCoordinates = updateEvent.pipe(\n        L.toStatelessProperty(() => {\n            return pageToBoardCoordinates(currentPageCoordinates.get())\n        }),\n    )\n\n    function scrollByPixels(diffPixels: { x: number; y: number }) {\n        scrollElem.get()!.scrollLeft += diffPixels.x\n        scrollElem.get()!.scrollTop += diffPixels.y\n    }\n\n    function scrollByBoardCoordinates(diffEm: { x: number; y: number }) {\n        const diffPx = {\n            x: emToPagePx(diffEm.x),\n            y: emToPagePx(diffEm.y),\n        }\n        scrollByPixels(diffPx)\n    }\n\n    return {\n        pageToBoardCoordinates,\n        pageCoordDiffToThisPoint,\n        currentBoardViewPortCoordinates,\n        currentPageCoordinates,\n        currentBoardCoordinates,\n        boardCoordDiffFromThisPageCoordinate: (coords: PageCoordinates) =>\n            coordDiff(currentBoardCoordinates.get(), pageToBoardCoordinates(coords)),\n        emToPagePx,\n        emToBoardPx,\n        pxToEm,\n        scrollByBoardCoordinates,\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/board-drag.ts",
    "content": "import { componentScope } from \"harmaja\"\nimport * as _ from \"lodash\"\nimport * as L from \"lonna\"\nimport { connectionRect } from \"../../../common/src/connection-utils\"\nimport { Board, Connection, Point } from \"../../../common/src/domain\"\nimport { containedBy, overlaps, Rect, rectFromPoints } from \"../../../common/src/geometry\"\nimport { Dispatch } from \"../store/server-connection\"\nimport { BoardCoordinateHelper, BoardCoordinates } from \"./board-coordinates\"\nimport { BoardFocus, getSelectedConnectionIds, getSelectedItemIds, isAnythingSelected, noFocus } from \"./board-focus\"\nimport { newConnectionCreator } from \"./item-connect\"\nimport { DND_GHOST_HIDING_IMAGE } from \"./item-drag\"\nimport { ToolController } from \"./tool-selection\"\nimport { onSingleTouch } from \"./touchScreen\"\n\nexport type DragAction =\n    | { action: \"select\"; selectedAtStart: BoardFocus }\n    | { action: \"pan\" }\n    | { action: \"none\" }\n    | { action: \"connect\" | \"line\"; startPos: Point }\n\nexport function boardDragHandler({\n    boardElem,\n    coordinateHelper,\n    latestConnection,\n    board,\n    toolController,\n    focus,\n    dispatch,\n}: {\n    boardElem: L.Property<HTMLElement | null>\n    coordinateHelper: BoardCoordinateHelper\n    latestConnection: L.Property<Connection | null>\n    board: L.Property<Board>\n    toolController: ToolController\n    focus: L.Atom<BoardFocus>\n    dispatch: Dispatch\n}) {\n    let start: L.Atom<BoardCoordinates | null> = L.atom(null)\n    let current: L.Atom<BoardCoordinates | null> = L.atom(null)\n    let rect: L.Property<Rect | null> = L.view(start, current, (s, c) => {\n        if (!s || !c) return null\n        return rectFromPoints(s, c)\n    })\n    const tool = toolController.tool\n\n    const connector = newConnectionCreator(board, focus, latestConnection, dispatch)\n\n    const dragAction = L.atom<DragAction>({ action: \"none\" })\n\n    boardElem.forEach((el) => {\n        if (!el) return\n        el.addEventListener(\"dragstart\", (e) => {\n            const t = tool.get()\n            const shouldDragSelect = t === \"pan\" ? !!e.altKey || !!e.shiftKey : !e.altKey\n            e.dataTransfer?.setDragImage(DND_GHOST_HIDING_IMAGE, 0, 0)\n            const pos = coordinateHelper.pageToBoardCoordinates({ x: e.pageX, y: e.pageY })\n            start.set(pos)\n            current.set(pos)\n            if (t === \"connect\") {\n                dragAction.set({ action: \"connect\", startPos: pos })\n            } else if (t === \"line\") {\n                dragAction.set({ action: \"line\", startPos: pos })\n            } else if (!shouldDragSelect) {\n                dragAction.set({ action: \"pan\" })\n            } else {\n                const f = focus.get()\n                const selectedAtStart = e.shiftKey ? f : noFocus\n                const anySelected = isAnythingSelected(selectedAtStart)\n                focus.set(\n                    anySelected\n                        ? {\n                              status: \"selected\",\n                              itemIds: getSelectedItemIds(selectedAtStart),\n                              connectionIds: getSelectedConnectionIds(selectedAtStart),\n                          }\n                        : noFocus,\n                )\n                dragAction.set({ action: \"select\", selectedAtStart })\n            }\n        })\n\n        el.addEventListener(\n            \"drag\",\n            _.throttle(\n                (e: DragEvent) => {\n                    const coords = coordinateHelper.currentBoardCoordinates.get()\n                    current.set(coords)\n                    const da = dragAction.get()\n                    if (da.action === \"select\") {\n                        const bounds = rect.get()!\n                        const startPoint = start.get()!\n                        const b = board.get()\n\n                        const itemIds = new Set([\n                            ...Object.values(b.items)\n                                .filter((i) => overlaps(i, bounds) && !containedBy(startPoint, i) && !i.hidden) // Do not select container if drag originates from within container\n                                .map((i) => i.id),\n                            ...getSelectedItemIds(da.selectedAtStart),\n                        ])\n\n                        const connectionIds = new Set([\n                            ...b.connections.filter((c) => overlaps(connectionRect(b)(c), bounds)).map((i) => i.id),\n                            ...getSelectedConnectionIds(da.selectedAtStart),\n                        ])\n\n                        itemIds.size + connectionIds.size > 0\n                            ? focus.set({ status: \"selected\", itemIds, connectionIds })\n                            : focus.set(noFocus)\n                    } else if (da.action === \"pan\") {\n                        const s = start.get()\n                        const c = current.get()\n                        s &&\n                            c &&\n                            (el.style.transform = `translate(${coordinateHelper.emToPagePx(\n                                c.x - s.x,\n                            )}px, ${coordinateHelper.emToPagePx(c.y - s.y)}px)`)\n                    } else if (da.action === \"connect\") {\n                        connector.whileDragging(da.startPos, coords, \"connect\")\n                    } else if (da.action === \"line\") {\n                        connector.whileDragging(da.startPos, coords, \"line\")\n                    }\n                },\n                15,\n                { leading: true, trailing: true },\n            ),\n        )\n\n        el.addEventListener(\"drop\", end)\n\n        el.addEventListener(\"dragend\", reset)\n\n        function reset() {\n            const da = dragAction.get()\n            if (da.action === \"pan\") {\n                boardElem.get()!.style.transform = \"translate(0, 0)\"\n                if (!start.get() || !current.get()) return\n                const s = document.querySelector(\".scroll-container\")!\n                const { x: startX, y: startY } = start.get()!\n                const { x, y } = current.get()!\n                const xDiff = coordinateHelper.emToPagePx(startX - x)\n                const yDiff = coordinateHelper.emToPagePx(startY - y)\n                s.scrollBy(xDiff, yDiff)\n            } else if (da.action === \"connect\" || da.action === \"line\") {\n                toolController.useDefaultTool()\n                connector.endDrag()\n            }\n            dragAction.set({ action: \"none\" })\n        }\n\n        function end() {\n            reset()\n            if (start.get()) {\n                start.set(null)\n                current.set(null)\n            }\n        }\n\n        let touchStart: Touch | null = null\n        function preventDefaultTouch(e: TouchEvent) {\n            if (e.target === boardElem.get()) {\n                e.preventDefault()\n            }\n        }\n        const onTouch = (e: TouchEvent) => {\n            preventDefaultTouch(e)\n            onSingleTouch(e, (touch) => {\n                if (touchStart) {\n                    const xDiff = touchStart.pageX - touch.pageX\n                    const yDiff = touchStart.pageY - touch.pageY\n                    const s = document.querySelector(\".scroll-container\")!\n                    s.scrollBy(xDiff, yDiff)\n                }\n                touchStart = touch\n            })\n        }\n        el.addEventListener(\"touchmove\", onTouch)\n        el.addEventListener(\"touchend\", (e) => {\n            preventDefaultTouch(e)\n            touchStart = null\n        })\n    })\n\n    return {\n        selectionRect: L.pipe(\n            L.combine(rect, dragAction, (rect: Rect | null, dragAction: DragAction) => {\n                if (!rect || dragAction.action !== \"select\") return null\n                return rect\n            }),\n            L.skipDuplicates<Rect | null>(_.isEqual, componentScope()),\n        ),\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/board-focus.ts",
    "content": "import { HarmajaChild } from \"harmaja\"\nimport { Board, Connection, findConnection, findItem, Id, Item } from \"../../../common/src/domain\"\nimport { difference, emptySet } from \"../../../common/src/sets\"\n\nexport type BoardFocus =\n    | { status: \"none\" }\n    | { status: \"selected\"; itemIds: Set<Id>; connectionIds: Set<Id> }\n    | { status: \"dragging\"; itemIds: Set<Id>; connectionIds: Set<Id> }\n    | { status: \"editing\"; itemId: Id }\n    | { status: \"adding\"; element: HarmajaChild; item: Item }\n    | { status: \"connection-adding\" }\n\nexport function getSelectedItemIds(f: BoardFocus): Set<Id> {\n    switch (f.status) {\n        case \"none\":\n        case \"adding\":\n        case \"connection-adding\":\n            return emptySet()\n        case \"editing\":\n            return new Set([f.itemId])\n        case \"selected\":\n        case \"dragging\":\n            return f.itemIds\n    }\n}\n\nexport function getSelectedConnectionIds(f: BoardFocus): Set<Id> {\n    switch (f.status) {\n        case \"none\":\n        case \"adding\":\n        case \"editing\":\n        case \"connection-adding\":\n            return emptySet()\n        case \"dragging\":\n        case \"selected\":\n            return f.connectionIds\n    }\n}\n\nexport const getSelectedItems = (b: Board) => (f: BoardFocus): Item[] => {\n    return [...getSelectedItemIds(f)].flatMap((id) => findItem(b)(id) || [])\n}\n\nexport const getSelectedConnections = (b: Board) => (f: BoardFocus): Connection[] => {\n    return [...getSelectedConnectionIds(f)].flatMap((id) => findConnection(b)(id) || [])\n}\n\nexport const getSelectedItem = (b: Board) => (f: BoardFocus): Item | null => {\n    return getSelectedItems(b)(f)[0] || null\n}\n\nexport const isAnythingSelected = (f: BoardFocus) =>\n    getSelectedConnectionIds(f).size > 0 || getSelectedItemIds(f).size > 0\n\nexport function removeFromSelection(\n    selection: BoardFocus,\n    toRemoveItems: Set<Id>,\n    toRemoveConnections: Set<Id>,\n): BoardFocus {\n    switch (selection.status) {\n        case \"adding\":\n        case \"none\":\n        case \"connection-adding\":\n            return selection\n        case \"editing\":\n            return toRemoveItems.has(selection.itemId) ? noFocus : selection\n        case \"dragging\":\n        case \"selected\":\n            selection = {\n                ...selection,\n                itemIds: difference(selection.itemIds, toRemoveItems),\n                connectionIds: difference(selection.connectionIds, toRemoveConnections),\n            }\n            return selection.itemIds.size + selection.connectionIds.size > 0 ? selection : noFocus\n    }\n}\n\nexport function removeNonExistingFromSelection(selection: BoardFocus, board: Board): BoardFocus {\n    const toRemoveItems = new Set(\n        [...getSelectedItemIds(selection)].filter((id) => {\n            if (!board.items[id]) return true\n            if (board.items[id].hidden) return true\n        }),\n    )\n    const selectedConnectionIds = getSelectedConnectionIds(selection)\n    const toRemoveConnections =\n        selectedConnectionIds.size > 0\n            ? difference(selectedConnectionIds, new Set(board.connections.filter((c) => !c.hidden).map((c) => c.id)))\n            : emptySet<Id>()\n    return removeFromSelection(selection, toRemoveItems, toRemoveConnections)\n}\n\nexport const noFocus: BoardFocus = { status: \"none\" }\n"
  },
  {
    "path": "frontend/src/board/board-permissions.ts",
    "content": "import { Connection, Item } from \"../../../common/src/domain\"\n\nexport const canChangeFont: BoardPermission = (item) => !item.locked\nexport const canChangeShapeAndColor: BoardPermission = (item): boolean => !item.locked\nexport const canChangeTextAlign: BoardPermission = (item): boolean => !item.locked\nexport const canChangeTextFormat: BoardPermission = (item): boolean => !item.locked\nexport const canChangeVisibility: BoardPermission = (item): boolean => !item.locked\nexport const canChangeText: BoardPermission = (item): boolean => true\nexport const canMove: BoardPermission = (item): boolean => !item.locked\nexport const canLock: BoardPermission = (item): boolean => !item.locked\nexport const canUnlock: BoardPermission = (item): boolean => item.locked === \"locked\"\nexport const canDelete: BoardPermission = (item): boolean => !item.locked\n\nexport type BoardPermission = (item: Item | Connection) => boolean\nexport const nullablePermission = (permission: BoardPermission) => (item: Item | Connection | null) =>\n    item === null ? false : permission(item)\n"
  },
  {
    "path": "frontend/src/board/board-scroll-and-zoom.ts",
    "content": "import * as H from \"harmaja\"\nimport { componentScope } from \"harmaja\"\nimport _, { clamp } from \"lodash\"\nimport * as L from \"lonna\"\nimport { Board } from \"../../../common/src/domain\"\nimport * as G from \"../../../common/src/geometry\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { ToolController } from \"./tool-selection\"\nimport { boardContentArea } from \"./boardContentArea\"\n\nexport type BoardZoom = { zoom: number; quickZoom: number }\nexport type ZoomAdjustMode = \"preserveCursor\" | \"preserveCenter\"\n\nexport function nonNull<A>(x: A | null | undefined): x is A {\n    return !!x\n}\n\nexport type ZoomAndScrollControls = ReturnType<typeof boardScrollAndZoomHandler>\n\nexport function boardScrollAndZoomHandler(\n    board: L.Property<Board>,\n    boardElement: L.Property<HTMLElement | null>,\n    scrollElement: L.Property<HTMLElement | null>,\n    zoom: L.Atom<BoardZoom>,\n    coordinateHelper: BoardCoordinateHelper,\n    toolController: ToolController,\n) {\n    const scrollPos = scrollElement.pipe(\n        L.changes,\n        L.filter(nonNull),\n        L.flatMapLatest(\n            (el) => L.fromEvent(el, \"scroll\").pipe(L.map(() => ({ x: el.scrollLeft, y: el.scrollTop }))),\n            componentScope(),\n        ),\n        L.toProperty(G.origin as { x: number; y: number }, componentScope()),\n    )\n\n    const scrollAndZoom = L.combine(scrollPos, zoom, (s, zoom) => ({ ...s, zoom }))\n\n    const localStorageKey = L.view(\n        board,\n        (b) => b.id,\n        (id) => \"scrollAndZoom.\" + id,\n    )\n\n    const boardIsNonEmpty = board.pipe(L.map((b) => b.name !== \"\"))\n    L.view(scrollElement, boardElement, localStorageKey, boardIsNonEmpty, (el, be, key, neb) => ({ el, be, key, neb }))\n        .pipe(L.applyScope(componentScope()))\n        .forEach(({ el, be, key, neb }) => {\n            if (el && be && key && neb) {\n                const storedScrollAndZoom = localStorage[key]\n                setTimeout(() => {\n                    if (storedScrollAndZoom) {\n                        const parsed = JSON.parse(storedScrollAndZoom)\n                        console.log(\"Restoring scroll and zoom for board from localStorage\", parsed.x, parsed.x)\n                        zoom.set({ zoom: parsed.zoom, quickZoom: 1 })\n                        el.scrollTop = parsed.y\n                        el.scrollLeft = parsed.x\n                    } else {\n                        viewRect.set(boardContentArea(board.get(), viewRect.get()))\n                    }\n                }, 0) // Need to wait for first render to have correct size. Causes a little flicker.\n            }\n        })\n\n    scrollAndZoom.pipe(L.changes, L.debounce(100), L.applyScope(componentScope())).forEach((s) => {\n        //console.log(\"Store position for board\", localStorageKey.get())\n        localStorage[localStorageKey.get()] = JSON.stringify({ ...s, zoom: s.zoom.zoom * s.zoom.quickZoom })\n    })\n\n    const changes = L.merge(\n        L.fromEvent(window, \"resize\"),\n        scrollPos.pipe(L.changes),\n        L.changes(boardElement),\n        L.changes(zoom),\n    )\n\n    const viewRectProp = changes.pipe(\n        L.throttle(0, componentScope()), // without the throttle/delay the rects below are not set correctly yet\n        L.toStatelessProperty(() => {\n            const boardRect = boardElement.get()?.getBoundingClientRect()\n            const viewRect = scrollElement.get()?.getBoundingClientRect()!\n\n            if (!boardRect || !viewRect) return G.ZERO_RECT\n\n            return {\n                x: coordinateHelper.pxToEm(viewRect.x - boardRect.x),\n                y: coordinateHelper.pxToEm(viewRect.y - boardRect.y),\n                width: coordinateHelper.pxToEm(viewRect.width),\n                height: coordinateHelper.pxToEm(viewRect.height),\n            }\n        }),\n        L.cached(componentScope()),\n    )\n\n    const viewRect = L.atom(viewRectProp, (newRect) => {\n        const currentRect = viewRectProp.get()\n        const factor = newRect.width / currentRect.width\n        zoom.modify((z) => ({ ...z, quickZoom: z.quickZoom / factor }))\n\n        const newX = coordinateHelper.emToBoardPx(newRect.x) / factor\n        const newY = coordinateHelper.emToBoardPx(newRect.y) / factor\n\n        scrollElement.get()!.scrollLeft = newX\n        scrollElement.get()!.scrollTop = newY\n    })\n\n    function wheelZoomHandler(event: WheelEvent) {\n        const ctrlOrCmd = event.ctrlKey || event.metaKey\n\n        // Wheel-zoom, or two finger zoom gesture on trackpad\n        if (ctrlOrCmd && event.deltaY !== 0) {\n            event.preventDefault()\n            const clampedStep = clamp(event.deltaY, -8, 8)\n            const step = Math.pow(1.01, -clampedStep)\n            adjustZoom({ scaleBy: step }, \"preserveCursor\")\n        } else {\n            // If the user seems to be using a trackpad, and they haven't manually selected a tool yet,\n            // Let's set the mode to 'select' as a best-effort \"works like you'd expect\" UX thing\n            const settings = toolController.controlSettings.get()\n            if (settings.defaultTool || settings.tool === \"select\") {\n                // Don't automatically make decisions for user if they have already set tool manually,\n                // Or if the select tool is already on\n                return\n            }\n\n            // On Firefox event.deltaMode is 0 on trackpad, 1 on mouse. Other browsers always 0.\n            // So we guess that user using trackpad if deltaMode == 0 and both deltaY/deltaX are sufficiently small (mousewheel is more coarse)\n            const isTrackpad = event.deltaMode === 0 && Math.max(Math.abs(event.deltaX), Math.abs(event.deltaY)) <= 3\n\n            if (isTrackpad) {\n                toolController.tool.set(\"select\")\n            }\n        }\n    }\n\n    const MAX_ZOOM = 10\n    const MIN_ZOOM = 0.1\n\n    zoom.pipe(L.changes, L.debounce(50, componentScope())).forEach((z) => {\n        if (z.quickZoom !== 1 && !scaleStart) {\n            const newZoom = clamp(z.zoom * z.quickZoom, MIN_ZOOM, MAX_ZOOM)\n            zoom.set({ zoom: newZoom, quickZoom: 1 })\n        }\n    })\n\n    function getViewRectCenter() {\n        const vr = viewRect.get()\n        return { x: vr.x + vr.width / 2, y: vr.y + vr.height / 2 }\n    }\n\n    type ZoomAdjustment = { scaleBy: number } | { setZoom: number }\n\n    function adjustZoom(change: ZoomAdjustment, mode: ZoomAdjustMode) {\n        if (mode === \"preserveCursor\") {\n            const prevCursor = coordinateHelper.currentBoardCoordinates.get()\n            justAdjustZoom(change)\n            const diffEm = G.subtract(prevCursor, coordinateHelper.currentBoardCoordinates.get())\n            coordinateHelper.scrollByBoardCoordinates(diffEm)\n        } else {\n            const prevCenterEm = getViewRectCenter()\n            const prevZoom = zoom.get()\n            justAdjustZoom(change)\n            const newZoom = zoom.get()\n            const ratio = (newZoom.zoom * newZoom.quickZoom) / (prevZoom.zoom * prevZoom.quickZoom)\n            const newCenterEm = G.multiply(prevCenterEm, 1 / ratio)\n            const diffEm = G.subtract(prevCenterEm, newCenterEm)\n            coordinateHelper.scrollByBoardCoordinates(diffEm)\n        }\n    }\n\n    function justAdjustZoom(change: ZoomAdjustment) {\n        if (\"scaleBy\" in change) {\n            zoom.modify((z) => {\n                return {\n                    quickZoom: _.clamp(z.quickZoom * change.scaleBy, MIN_ZOOM / z.zoom, MAX_ZOOM / z.zoom),\n                    zoom: z.zoom,\n                }\n            })\n        } else {\n            zoom.set({ quickZoom: 1, zoom: change.setZoom })\n        }\n    }\n\n    let scaleStart: number | null = null\n\n    function onGestureStart(e: any) {\n        e.preventDefault()\n        const scale = typeof e.scale === \"number\" && (e.scale as number)\n        if (scale) {\n            scaleStart = (zoom.get().quickZoom * zoom.get().zoom) / scale\n        }\n    }\n\n    function onGestureChange(e: any) {\n        e.preventDefault()\n        const scale = typeof e.scale === \"number\" && (e.scale as number)\n        if (scale && scaleStart) {\n            adjustZoom({ setZoom: scale * scaleStart }, \"preserveCursor\")\n        }\n    }\n\n    function onGestureEnd(e: any) {\n        onGestureChange(e)\n        scaleStart = null\n    }\n\n    function increaseZoom(adjustMode: ZoomAdjustMode) {\n        adjustZoom({ scaleBy: 1.2 }, adjustMode)\n    }\n\n    function decreaseZoom(adjustMode: ZoomAdjustMode) {\n        adjustZoom({ scaleBy: 1 / 1.2 }, adjustMode)\n    }\n\n    function resetZoom(adjustMode: ZoomAdjustMode) {\n        adjustZoom({ setZoom: 1 }, adjustMode)\n    }\n\n    H.onMount(() => {\n        // have to use this for chrome: https://stackoverflow.com/questions/42101723/unable-to-preventdefault-inside-passive-event-listener\n        document.addEventListener(\"wheel\", wheelZoomHandler, { passive: false })\n        document.addEventListener(\"gesturestart\", onGestureStart)\n        document.addEventListener(\"gesturechange\", onGestureChange)\n        document.addEventListener(\"gestureend\", onGestureEnd)\n    })\n    H.onUnmount(() => {\n        document.removeEventListener(\"wheel\", wheelZoomHandler)\n        document.removeEventListener(\"gesturestart\", onGestureStart)\n        document.removeEventListener(\"gesturechange\", onGestureChange)\n        document.removeEventListener(\"gestureend\", onGestureChange)\n    })\n    return {\n        viewRect,\n        adjustZoom,\n        increaseZoom,\n        decreaseZoom,\n        resetZoom,\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/boardContentArea.ts",
    "content": "import _ from \"lodash\"\nimport { Board } from \"../../../common/src/domain\"\nimport { Rect } from \"../../../common/src/geometry\"\n\nfunction combineRects(r1: Rect, r2: Rect): Rect {\n    const left = Math.min(r1.x, r2.x)\n    const top = Math.min(r1.y, r2.y)\n    const right = Math.max(r1.x + r1.width, r2.x + r2.width)\n    const bottom = Math.max(r1.y + r1.height, r2.y + r2.height)\n    return { x: left, y: top, width: right - left, height: bottom - top }\n}\n\nfunction itemToRect(item: Rect): Rect {\n    return { x: item.x, y: item.y, width: item.width, height: item.height }\n}\n\nfunction addMargin(rect: Rect, margin: number): Rect {\n    return {\n        x: rect.x - margin,\n        y: rect.y - margin,\n        width: rect.width + 2 * margin,\n        height: rect.height + 2 * margin,\n    }\n}\n\nfunction setMinimumSizeKeepingCenter(rect: Rect, minimumSize: { width: number; height: number }) {\n    const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }\n    const width = Math.max(rect.width, minimumSize.width)\n    const height = Math.max(rect.height, minimumSize.height)\n    return {\n        x: center.x - width / 2,\n        y: center.y - height / 2,\n        width,\n        height,\n    }\n}\n\nfunction clampIntoKeepingSize(rect: Rect, bounds: Rect) {\n    const minLeft = bounds.x\n    const minTop = bounds.y\n    const maxLeft = bounds.x + bounds.width - rect.width\n    const maxTop = bounds.y + bounds.height - rect.height\n    const x = _.clamp(rect.x, minLeft, maxLeft)\n    const y = _.clamp(rect.y, minTop, maxTop)\n    return { x, y, width: rect.width, height: rect.height }\n}\n\nfunction growToProportions(rect: Rect, proportions: { width: number; height: number }) {\n    const width = rect.width\n    const height = rect.height\n    const targetWidth = rect.height * (proportions.width / proportions.height)\n    const targetHeight = rect.width * (proportions.height / proportions.width)\n    if (targetWidth > rect.width) {\n        const diff = targetWidth - rect.width\n        rect.x -= diff / 2\n        rect.width = targetWidth\n    } else {\n        const diff = targetHeight - rect.height\n        rect.y -= diff / 2\n        rect.height = targetHeight\n    }\n    return rect\n}\n\nexport function boardContentArea(b: Board, viewPortDimensions: { width: number; height: number }) {\n    // Default / minimum size for initial view\n    const width = b.width / 10\n    const height = b.height / 10\n\n    const items = Object.values(b.items)\n\n    if (!items.length) {\n        console.log(\"No items in board, centering view\")\n        return addMargin({ x: b.width / 2 - width / 2, y: b.height / 2 - height / 2, width, height }, height * 0.1)\n    }\n    console.log(`${items.length} items on board, calculating view area`)\n    let itemsArea = itemToRect(items[0])\n    items.forEach((item) => {\n        itemsArea = combineRects(itemsArea, itemToRect(item))\n    })\n\n    // Now we have the area of all items, let's add some margin\n    itemsArea = addMargin(itemsArea, width * 0.2)\n\n    itemsArea = growToProportions(itemsArea, viewPortDimensions)\n\n    // Grow to at least the default size keeping center point\n    itemsArea = setMinimumSizeKeepingCenter(itemsArea, { width, height })\n\n    // Clamp to board limits\n    itemsArea = clampIntoKeepingSize(itemsArea, { x: 0, y: 0, width: b.width, height: b.height })\n    return itemsArea\n}\n"
  },
  {
    "path": "frontend/src/board/contextmenu/ContextMenuView.tsx",
    "content": "import { h, HarmajaOutput, ListView } from \"harmaja\"\nimport _ from \"lodash\"\nimport * as L from \"lonna\"\nimport { Board, Connection, findItem, Id, Item } from \"../../../../common/src/domain\"\nimport { Dispatch } from \"../../store/board-store\"\nimport { BoardFocus, getSelectedConnections, getSelectedItems } from \"../board-focus\"\nimport { Rect } from \"../../../../common/src/geometry\"\nimport { alignmentsMenu } from \"./alignments\"\nimport { areaTilingMenu } from \"./areaTiling\"\nimport { colorsAndShapesMenu } from \"./colorsAndShapes\"\nimport { fontSizesMenu } from \"./fontSizes\"\nimport { resolveEndpoint } from \"../../../../common/src/connection-utils\"\nimport { connectionEndsMenu } from \"./connection-ends\"\nimport { textAlignmentsMenu } from \"./textAlignments\"\nimport { lockMenu } from \"./lock\"\nimport { hideContentsMenu } from \"./hideContents\"\nimport { textFormatsMenu } from \"./textFormats\"\nimport { nonNull } from \"../board-scroll-and-zoom\"\n\nexport type SubmenuProps = {\n    focusedItems: L.Property<{ items: Item[]; connections: Connection[] }>\n    board: L.Property<Board>\n    dispatch: Dispatch\n    submenu: L.Atom<SubMenuCreator | null>\n}\n\nexport type SubMenuCreator = (props: SubmenuProps) => HarmajaOutput\n\nexport const connectionPos = (b: Board | Record<string, Item>) => (c: Connection): Rect => {\n    if (c.controlPoints.length === 0) {\n        const start = resolveEndpoint(c.from, b)\n        const end = resolveEndpoint(c.to, b)\n        return { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2, width: 0, height: 0 }\n    }\n    const p = c.controlPoints[0]\n    return { ...p, width: 0, height: 0 }\n}\n\ntype ItemsAndConnections = { items: Item[]; connections: Connection[] }\ntype Bounds = { minX: number; maxX: number; minY: number; maxY: number } | null\n\nconst getSelectionBounds = (items: ItemsAndConnections, b: Board): Bounds => {\n    if (items.items.length === 0 && items.connections.length === 0) {\n        return null\n    }\n    const rects = [...items.items, ...items.connections.map(connectionPos(b))]\n    const minY = _.min(rects.map((i) => i.y)) || 0\n    const minX = _.min(rects.map((i) => i.x)) || 0\n    const maxY = _.max(rects.map((i) => i.y + i.height)) || 0\n    const maxX = _.max(rects.map((i) => i.x + i.width)) || 0\n    return { minX, maxX, minY, maxY }\n}\n\nconst getStyleAndClass = (bounds: Bounds) => (vr: Rect) => {\n    const cn = \"context-menu-positioner\"\n    if (bounds === null) {\n        return {\n            style: null,\n            className: cn,\n        }\n    }\n    const { minX, maxX, minY, maxY } = bounds\n    const alignRight = minX > vr.x + vr.width / 2\n    const topOfItem = minY - vr.y > vr.height / 3\n    return {\n        style: {\n            left: alignRight ? undefined : `max(${minX}em, ${vr.x}em)`,\n            right: alignRight ? `calc(100% - min(${maxX}em, ${vr.x + vr.width}em))` : undefined,\n            top: topOfItem ? `${minY}em` : `${maxY}em`,\n        },\n        className: cn + (topOfItem ? \" item-top\" : \" item-bottom\"),\n    }\n}\n\nexport const ContextMenuView = ({\n    dispatch,\n    board,\n    focus,\n    viewRect,\n}: {\n    dispatch: Dispatch\n    board: L.Property<Board>\n    focus: L.Property<BoardFocus>\n    viewRect: L.Property<Rect>\n}) => {\n    const focusedItems = L.view(focus, board, (f, b) => {\n        if (f.status === \"dragging\" || f.status === \"connection-adding\" || f.status === \"adding\")\n            return { items: [], connections: [] }\n        return { items: getSelectedItems(b)(f), connections: getSelectedConnections(b)(f) }\n    })\n\n    const submenu = L.atom<SubMenuCreator | null>(null)\n    L.view(\n        focusedItems,\n        (items) => items.items[0],\n        (i) => i?.id,\n    ).forEach(() => submenu.set(null))\n\n    const props = { board, focusedItems, dispatch, submenu }\n    const widgetCreators = [\n        alignmentsMenu(\"x\", props),\n        alignmentsMenu(\"y\", props),\n        colorsAndShapesMenu(props),\n        fontSizesMenu(props),\n        textFormatsMenu(props),\n        textAlignmentsMenu(props),\n        areaTilingMenu(props),\n        connectionEndsMenu(props),\n        hideContentsMenu(props),\n        lockMenu(props),\n    ]\n    const activeWidgets = L.view(L.combineAsArray(widgetCreators), (arrays) => arrays.flat().filter(nonNull))\n    const captureEvents = (e: JSX.MouseEvent) => {\n        e.stopPropagation()\n    }\n    return L.view(\n        activeWidgets,\n        (ws) => ws.length === 0,\n        (hide) => {\n            if (hide) return null\n            const bounds = L.view(focus, () => getSelectionBounds(focusedItems.get(), board.get()))\n            const styleAndClass = L.view(viewRect, bounds, (vr, b) => getStyleAndClass(b)(vr))\n\n            return (\n                <div\n                    className={L.view(styleAndClass, \"className\")}\n                    style={L.view(styleAndClass, \"style\")}\n                    onDoubleClick={captureEvents}\n                    onClick={captureEvents}\n                >\n                    <div className=\"context-menu\">\n                        <ListView observable={activeWidgets} renderItem={(x) => x} getKey={(x) => x} />\n                    </div>\n                    {L.view(submenu, (show) => (show ? show(props) : null))}\n                </div>\n            )\n        },\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/contextmenu/alignments.tsx",
    "content": "import { componentScope, h } from \"harmaja\"\nimport _ from \"lodash\"\nimport * as L from \"lonna\"\nimport { Item } from \"../../../../common/src/domain\"\nimport {\n    AlignHorizontalCenterIcon,\n    AlignHorizontalLeftIcon,\n    AlignHorizontalRightIcon,\n    AlignVerticalBottomIcon,\n    AlignVerticalCenterIcon,\n    AlignVerticalTopIcon,\n    HorizontalDistributeIcon,\n    VerticalDistributeIcon,\n} from \"../../components/Icons\"\nimport { SubmenuProps } from \"./ContextMenuView\"\nimport { canMove } from \"../board-permissions\"\n\nconst createSubMenuByAxis = (axis: Axis) => (props: SubmenuProps) => {\n    return <div className={`submenu alignment ${axis}`}>{alignmentsSubMenu(axis, props)}</div>\n}\n\nexport function alignmentsMenu(axis: Axis, props: SubmenuProps) {\n    const hasItemsToAlign = L.view(props.focusedItems, (items) => items.items.length > 1)\n    const hasItemsToDistribute = L.view(props.focusedItems, (items) => items.items.length > 2)\n    const createSubmenu = createSubMenuByAxis(axis)\n    const enabled = L.view(props.focusedItems, (items) => items.items.some(canMove))\n\n    return L.combine(hasItemsToAlign, hasItemsToDistribute, (hasItemsToAlign, hasItemsToDistribute) => {\n        return !hasItemsToAlign && !hasItemsToDistribute\n            ? []\n            : [\n                  <div className=\"icon-group align\">\n                      {hasItemsToAlign && (\n                          <span\n                              className={L.view(enabled, (e) => (e ? \"icon\" : \"icon disabled\"))}\n                              onClick={() => props.submenu.modify((v) => (v === createSubmenu ? null : createSubmenu))}\n                              title={axis === \"x\" ? \"Align left\" : \"Align top\"}\n                          >\n                              {axis == \"x\" ? <AlignHorizontalLeftIcon /> : <AlignVerticalTopIcon />}\n                          </span>\n                      )}\n                  </div>,\n              ]\n    })\n}\n\ntype Axis = \"x\" | \"y\"\ntype GetCoordinate = (\n    item: Item,\n    min: number,\n    max: number,\n    axis: Axis,\n    index: number,\n    numberOfItems: number,\n    sumOfPreviousSizes: number,\n    totalSumOfSizes: number,\n) => number\n\nfunction getItemSize(item: Item, axis: Axis) {\n    return axis === \"x\" ? item.width : item.height\n}\n\nfunction moveFocusedItems(\n    axis: Axis,\n    getCoordinateToSetToItem: GetCoordinate,\n    { board, focusedItems, dispatch }: SubmenuProps,\n) {\n    const b = board.get()\n\n    const itemsToMove = focusedItems.get().items\n    const min = _.min(itemsToMove.map((i) => i[axis])) || 0\n    const max = _.max(itemsToMove.map((i) => i[axis] + getItemSize(i, axis))) || 0\n    const totalSumOfSizes = _.sum(itemsToMove.map((i) => getItemSize(i, axis), 0))\n\n    let sumOfPreviousSizes = 0\n    const updatedItems = focusedItems\n        .get()\n        .items.sort((item1, item2) => item1[axis] - item2[axis])\n        .map((item, index) => {\n            const newItem = {\n                id: item.id,\n                [axis]: getCoordinateToSetToItem(\n                    item,\n                    min,\n                    max,\n                    axis,\n                    index,\n                    itemsToMove.length,\n                    sumOfPreviousSizes,\n                    totalSumOfSizes,\n                ),\n            }\n            sumOfPreviousSizes += getItemSize(item, axis)\n            return newItem\n        })\n    dispatch({ action: \"item.update\", boardId: b.id, items: updatedItems })\n}\n\nexport function alignmentsSubMenu(axis: Axis, props: SubmenuProps) {\n    const hasItemsToAlign = L.view(props.focusedItems, (items) => items.items.length > 1)\n    const hasItemsToDistribute = L.view(props.focusedItems, (items) => items.items.length > 2)\n\n    const getMinCoordinate: GetCoordinate = (_, min) => min\n\n    const getCenterCoordinate: GetCoordinate = (item, min, max, axis) => (min + max - getItemSize(item, axis)) / 2\n\n    const getMaxCoordinate: GetCoordinate = (item, min, max, axis) => max - getItemSize(item, axis)\n\n    const getDistributedCoordinate: GetCoordinate = (\n        item,\n        min,\n        max,\n        _,\n        index,\n        numberOfItems,\n        sumOfPreviousSizes,\n        totalSumOfSizes,\n    ) => {\n        const spaceBetweenItems = (max - min - totalSumOfSizes) / (numberOfItems - 1)\n        return min + sumOfPreviousSizes + index * spaceBetweenItems\n    }\n    return L.combine(hasItemsToAlign, hasItemsToDistribute, (hasItemsToAlign, hasItemsToDistribute) => {\n        return !hasItemsToAlign\n            ? []\n            : axis == \"x\"\n            ? [\n                  <div className=\"icon-group align\">\n                      {hasItemsToAlign && (\n                          <span\n                              className=\"icon\"\n                              onClick={() => moveFocusedItems(\"x\", getMinCoordinate, props)}\n                              title=\"Align left\"\n                          >\n                              <AlignHorizontalLeftIcon />\n                          </span>\n                      )}\n\n                      {hasItemsToAlign && (\n                          <span\n                              className=\"icon\"\n                              onClick={() => moveFocusedItems(\"x\", getCenterCoordinate, props)}\n                              title=\"Align center\"\n                          >\n                              <AlignHorizontalCenterIcon />\n                          </span>\n                      )}\n\n                      {hasItemsToAlign && (\n                          <span\n                              className=\"icon\"\n                              onClick={() => moveFocusedItems(\"x\", getMaxCoordinate, props)}\n                              title=\"Align right\"\n                          >\n                              <AlignHorizontalRightIcon />\n                          </span>\n                      )}\n\n                      {hasItemsToDistribute && (\n                          <span\n                              className=\"icon\"\n                              title=\"Distribute evenly\"\n                              onClick={() => moveFocusedItems(\"x\", getDistributedCoordinate, props)}\n                          >\n                              <HorizontalDistributeIcon />\n                          </span>\n                      )}\n                  </div>,\n              ]\n            : [\n                  <div className=\"icon-group align\">\n                      {hasItemsToAlign && (\n                          <span\n                              className=\"icon\"\n                              title=\"Align top\"\n                              onClick={() => moveFocusedItems(\"y\", getMinCoordinate, props)}\n                          >\n                              <AlignVerticalTopIcon />\n                          </span>\n                      )}\n\n                      {hasItemsToAlign && (\n                          <span\n                              className=\"icon\"\n                              title=\"Align middle\"\n                              onClick={() => moveFocusedItems(\"y\", getCenterCoordinate, props)}\n                          >\n                              <AlignVerticalCenterIcon />\n                          </span>\n                      )}\n\n                      {hasItemsToAlign && (\n                          <span\n                              className=\"icon\"\n                              title=\"Align bottom\"\n                              onClick={() => moveFocusedItems(\"y\", getMaxCoordinate, props)}\n                          >\n                              <AlignVerticalBottomIcon />\n                          </span>\n                      )}\n                      {hasItemsToDistribute && (\n                          <span\n                              className=\"icon\"\n                              title=\"Distribute evenly vertically\"\n                              onClick={() => moveFocusedItems(\"y\", getDistributedCoordinate, props)}\n                          >\n                              <VerticalDistributeIcon />\n                          </span>\n                      )}\n                  </div>,\n              ]\n    })\n}\n"
  },
  {
    "path": "frontend/src/board/contextmenu/areaTiling.tsx",
    "content": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, Container, Item, findItem, isContainer } from \"../../../../common/src/domain\"\nimport { TileIcon } from \"../../components/Icons\"\nimport { contentRect, organizeItems, packableItems } from \"../item-organizer\"\nimport { packItems } from \"../item-packer\"\nimport { SubmenuProps } from \"./ContextMenuView\"\nimport { canMove } from \"../board-permissions\"\n\nexport function areaTilingMenu({ board, focusedItems, dispatch }: SubmenuProps) {\n    const packables = L.view(focusedItems, (items) => {\n        if (items.items.length === 1) {\n            if (isContainer(items.items[0])) return items.items\n        }\n        if (items.items.length >= 1) {\n            const containerIds = new Set(items.items.map((i) => i.containerId))\n            if (containerIds.size === 1 && [...containerIds][0]) return items.items\n        }\n        return []\n    })\n    const enabled = L.view(packables, (items) => items.some(canMove))\n    const className = enabled.pipe(L.map((e) => (e ? \"icon\" : \"icon disabled\")))\n\n    return L.view(\n        packables,\n        (ps) => ps.length > 0,\n        (show) =>\n            show\n                ? [\n                      <div className=\"icon-group area-options\">\n                          <span\n                              className={className}\n                              title=\"Organize contents\"\n                              onClick={() => packArbitraryItems(packables.get())}\n                          >\n                              <TileIcon />\n                          </span>\n                      </div>,\n                  ]\n                : [],\n    )\n\n    function packArbitraryItems(items: Item[]) {\n        const b = board.get()\n        if (items.length === 1 && isContainer(items[0])) {\n            packItemsInsideContainer(items[0], b)\n        } else {\n            packItemsInsideContainer(findItem(b)(items[0].containerId!) as Container, b)\n        }\n    }\n    function packItemsInsideContainer(container: Container, b: Board) {\n        const targetRect = contentRect(container)\n        const itemsToPack = packableItems(container, b)\n        let organizedItems = organizeItems(itemsToPack, [], targetRect)\n        if (organizedItems.length === 0) {\n            console.log(\"Packing\")\n            // Already organized -> Pack into equal size to fit\n            const packResult = packItems(targetRect, itemsToPack)\n\n            if (!packResult.ok) {\n                console.error(\"Packing container failed: \" + packResult.error)\n                return\n            }\n            organizedItems = packResult.packedItems\n        }\n\n        dispatch({ action: \"item.update\", boardId: board.get().id, items: organizedItems })\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/contextmenu/colors.tsx",
    "content": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { DEFAULT_NOTE_COLOR, NOTE_COLORS, TRANSPARENT } from \"../../../../common/src/colors\"\nimport { Color, Item, isColoredItem } from \"../../../../common/src/domain\"\nimport { SubmenuProps } from \"./ContextMenuView\"\n\nexport function colorsSubMenu({ board, focusedItems, dispatch }: SubmenuProps) {\n    const coloredItems = L.view(focusedItems, (items) => items.items.filter(isColoredItem))\n    const anyColored = L.view(coloredItems, (items) => items.length > 0)\n\n    return L.view(anyColored, (anyColored) => {\n        return !anyColored\n            ? []\n            : [\n                  <div className=\"colors icon-group\">\n                      {NOTE_COLORS.map((color) => {\n                          return (\n                              <span\n                                  className={`icon color ${color.name}`}\n                                  style={{ background: color.color === TRANSPARENT ? undefined : color.color }}\n                                  onClick={() => setColor(color.color)}\n                              />\n                          )\n                      })}\n                      <span className={\"icon color new-color\"}>\n                          <input\n                              type=\"color\"\n                              onInput={(e) => setColor(e.target.value)}\n                              value={itemColorOrDefault(focusedItems.get().items)}\n                          />\n                      </span>\n                  </div>,\n              ]\n    })\n\n    function setColor(color: Color) {\n        const b = board.get()\n        const updated = coloredItems.get().map((item) => ({ id: item.id, color }))\n        dispatch({ action: \"item.update\", boardId: b.id, items: updated })\n    }\n}\n\nfunction itemColorOrDefault(items: Item[]) {\n    const firstNoteWithColor = items.find(isColoredItem)\n    if (!firstNoteWithColor) return DEFAULT_NOTE_COLOR\n    return firstNoteWithColor.color\n}\n"
  },
  {
    "path": "frontend/src/board/contextmenu/colorsAndShapes.tsx",
    "content": "import { componentScope, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { NOTE_COLORS } from \"../../../../common/src/colors\"\nimport { ColoredItem, isColoredItem } from \"../../../../common/src/domain\"\nimport { colorsSubMenu } from \"./colors\"\nimport { SubmenuProps } from \"./ContextMenuView\"\nimport { getShapeIcon, shapesSubMenu } from \"./shapes\"\nimport { disabledColor } from \"../../components/UIColors\"\nimport { canChangeShapeAndColor } from \"../board-permissions\"\n\nfunction createSubMenu(props: SubmenuProps) {\n    return (\n        <div className=\"submenu\">\n            {colorsSubMenu(props)}\n            {shapesSubMenu(props)}\n        </div>\n    )\n}\n\nexport function colorsAndShapesMenu(props: SubmenuProps) {\n    const coloredItems = L.view(props.focusedItems, (items) => items.items.filter(isColoredItem))\n    const representativeColoredItem: L.Property<ColoredItem | null> = L.view(coloredItems, (items) => items[0] || null)\n    const enabled = L.view(coloredItems, (items) => items.some(canChangeShapeAndColor))\n    return L.view(representativeColoredItem, enabled, (item, enabled) => {\n        if (!item) return []\n        const color = NOTE_COLORS.find((c) => c.color === item.color) || { name: \"custom\", color: item.color }\n        const shapeIcon = getShapeIcon(item)\n\n        return !item\n            ? []\n            : [\n                  <div className=\"colors-shapes icon-group\">\n                      <span\n                          className={`icon color ${color.name} ${enabled ? \"\" : \"disabled\"}`}\n                          onClick={() => props.submenu.modify((v) => (v == createSubMenu ? null : createSubMenu))}\n                      >\n                          {shapeIcon(enabled ? color.color : disabledColor, enabled ? color.color : undefined)}\n                      </span>\n                  </div>,\n              ]\n    })\n}\n"
  },
  {
    "path": "frontend/src/board/contextmenu/connection-ends.tsx",
    "content": "import { componentScope, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { rerouteConnection } from \"../../../../common/src/connection-utils\"\nimport { ConnectionEndStyle } from \"../../../../common/src/domain\"\nimport {\n    ConnectionCenterCurveDotIcon,\n    ConnectionCenterCurveIcon,\n    ConnectionCenterLineIcon,\n    ConnectionEndLineIcon,\n    ConnectionLeftArrowIcon,\n    ConnectionLeftDotIcon,\n    ConnectionRightArrowIcon,\n    ConnectionRightDotIcon,\n} from \"../../components/Icons\"\nimport { SubmenuProps } from \"./ContextMenuView\"\nimport { canChangeShapeAndColor } from \"../board-permissions\"\n\nconst styles: ConnectionEndStyle[] = [\"arrow\", \"black-dot\", \"none\"]\nfunction nextStyle(style: ConnectionEndStyle) {\n    const i = styles.indexOf(style)\n    return styles[(i + 1) % styles.length]\n}\n\nexport function connectionEndsMenu({ board, focusedItems, dispatch }: SubmenuProps) {\n    const connections = L.view(focusedItems, (items) => items.connections)\n    const singleConnection = L.view(connections, (connections) =>\n        connections.length === 1 && connections[0].action === \"connect\" ? connections[0] : null,\n    )\n    const enabled = L.view(connections, (connections) => connections.some(canChangeShapeAndColor))\n    const className = enabled.pipe(L.map((e) => (e ? \"icon\" : \"icon disabled\")))\n\n    return L.view(singleConnection, (connection) => {\n        if (!connection) return []\n        return !connection\n            ? []\n            : [\n                  <div className=\"connection-ends icon-group\">\n                      <span\n                          className={className}\n                          onClick={() =>\n                              dispatch({\n                                  action: \"connection.modify\",\n                                  boardId: board.get().id,\n                                  connections: [{ ...connection, fromStyle: nextStyle(connection.fromStyle) }],\n                              })\n                          }\n                      >\n                          {connection.fromStyle === \"arrow\" ? (\n                              <ConnectionLeftArrowIcon />\n                          ) : connection.fromStyle === \"black-dot\" ? (\n                              <ConnectionLeftDotIcon />\n                          ) : (\n                              <ConnectionEndLineIcon />\n                          )}\n                      </span>\n                      <span\n                          className={className}\n                          onClick={() =>\n                              dispatch({\n                                  action: \"connection.modify\",\n                                  boardId: board.get().id,\n                                  connections: [\n                                      connection.controlPoints.length === 0\n                                          ? rerouteConnection(\n                                                {\n                                                    ...connection,\n                                                    controlPoints: [{ x: 0, y: 0 }],\n                                                    pointStyle: \"black-dot\",\n                                                },\n                                                board.get(),\n                                            )\n                                          : connection.pointStyle === \"black-dot\"\n                                          ? { ...connection, pointStyle: \"none\" }\n                                          : { ...connection, controlPoints: [] },\n                                  ],\n                              })\n                          }\n                      >\n                          {connection.controlPoints.length === 0 ? (\n                              <ConnectionCenterLineIcon />\n                          ) : connection.pointStyle === \"black-dot\" ? (\n                              <ConnectionCenterCurveDotIcon />\n                          ) : (\n                              <ConnectionCenterCurveIcon />\n                          )}\n                      </span>\n                      <span\n                          className={className}\n                          onClick={() =>\n                              dispatch({\n                                  action: \"connection.modify\",\n                                  boardId: board.get().id,\n                                  connections: [{ ...connection, toStyle: nextStyle(connection.toStyle) }],\n                              })\n                          }\n                      >\n                          {connection.toStyle === \"arrow\" ? (\n                              <ConnectionRightArrowIcon />\n                          ) : connection.toStyle === \"black-dot\" ? (\n                              <ConnectionRightDotIcon />\n                          ) : (\n                              <ConnectionEndLineIcon />\n                          )}\n                      </span>\n                  </div>,\n              ]\n    })\n}\n"
  },
  {
    "path": "frontend/src/board/contextmenu/fontSizes.tsx",
    "content": "import { HarmajaOutput, componentScope, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { isTextItem } from \"../../../../common/src/domain\"\nimport { DecreaseFontSizeIcon, IncreaseFontSizeIcon } from \"../../components/Icons\"\nimport { SubmenuProps } from \"./ContextMenuView\"\nimport { black, disabledColor } from \"../../components/UIColors\"\nimport { canChangeFont } from \"../board-permissions\"\n\ntype MenuIconProps = {\n    onClick: () => void\n    title: string\n    icon: HarmajaOutput\n    enabled: L.Property<boolean>\n}\nexport const MenuIcon = (props: MenuIconProps) => {\n    return (\n        <span\n            className=\"icon\"\n            style={props.enabled.pipe(L.map((e) => ({ color: e ? black : disabledColor })))}\n            onClick={L.view(props.enabled, (e) => (e ? props.onClick : undefined))}\n            title={props.title}\n        >\n            {props.icon}\n        </span>\n    )\n}\n\nexport function fontSizesMenu({ board, focusedItems, dispatch }: SubmenuProps) {\n    const textItems = L.view(focusedItems, (items) => items.items.filter(isTextItem))\n    const anyText = L.view(textItems, (items) => items.length > 0)\n    const enabled = L.view(textItems, (items) => items.some(canChangeFont))\n    const className = enabled.pipe(L.map((e) => (e ? \"icon\" : \"icon disabled\")))\n\n    return L.view(anyText, (any) =>\n        !any\n            ? []\n            : [\n                  <div className=\"font-size icon-group\">\n                      <span className={className} onClick={increaseFont} title=\"Bigger font\">\n                          <IncreaseFontSizeIcon />\n                      </span>\n                      <span className={className} onClick={decreaseFont} title=\"Smaller font\">\n                          <DecreaseFontSizeIcon />\n                      </span>\n                  </div>,\n              ],\n    )\n\n    function increaseFont() {\n        if (!enabled.get()) return\n        dispatch({\n            action: \"item.font.increase\",\n            boardId: board.get().id,\n            itemIds: textItems.get().map((i) => i.id),\n        })\n    }\n    function decreaseFont() {\n        if (!enabled.get()) return\n        dispatch({\n            action: \"item.font.decrease\",\n            boardId: board.get().id,\n            itemIds: textItems.get().map((i) => i.id),\n        })\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/contextmenu/hideContents.tsx",
    "content": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { isContainer } from \"../../../../common/src/domain\"\nimport { VisibilityIcon, VisibilityOffIcon } from \"../../components/Icons\"\nimport { canChangeVisibility } from \"../board-permissions\"\nimport { hasContentHidden, toggleContentsHidden } from \"../item-hide-contents\"\nimport { SubmenuProps } from \"./ContextMenuView\"\n\nexport function hideContentsMenu({ board, focusedItems, dispatch }: SubmenuProps) {\n    const containers = L.view(focusedItems, (items) => items.items.filter(isContainer))\n\n    const containersOrContained = L.view(focusedItems, (items) =>\n        items.items.filter((i) => isContainer(i) || !!i.containerId),\n    )\n    const hasContainers = L.view(containersOrContained, (cs) => cs.length > 0)\n    const enabled = L.view(containersOrContained, (items) => items.some(canChangeVisibility))\n\n    const className = enabled.pipe(L.map((e) => (e ? \"icon\" : \"icon disabled\")))\n    const currentlyHidden = L.view(containers, hasContentHidden)\n\n    return L.view(hasContainers, currentlyHidden, (hasContainers, hidden) => {\n        return !hasContainers\n            ? []\n            : [\n                  <div className=\"icon-group visibility\">\n                      <span\n                          className={className}\n                          onClick={() => {\n                              toggleContentsHidden(focusedItems.get().items, board.get(), dispatch)\n                          }}\n                          title={hidden ? \"Show contents\" : \"Hide contents\"}\n                      >\n                          {hidden ? <VisibilityOffIcon /> : <VisibilityIcon />}\n                      </span>\n                  </div>,\n              ]\n    })\n}\n"
  },
  {
    "path": "frontend/src/board/contextmenu/lock.tsx",
    "content": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { LockIcon, UnlockIcon } from \"../../components/Icons\"\nimport * as P from \"../board-permissions\"\nimport { SubmenuProps } from \"./ContextMenuView\"\nimport { LockState } from \"../../../../common/src/domain\"\n\nexport function lockMenu({ board, focusedItems, dispatch }: SubmenuProps) {\n    const canLock = L.view(focusedItems, (items) => items.items.some(P.canLock) || items.connections.some(P.canLock))\n\n    const canUnlock = L.view(\n        focusedItems,\n        (items) => items.items.some(P.canUnlock) || items.connections.some(P.canUnlock),\n    )\n\n    const showUnlock = L.view(canLock, canUnlock, (l, u) => !l && u)\n    const enabled = L.view(canLock, canUnlock, (l, u) => l || u)\n\n    const hasItems = L.view(focusedItems, (ps) => ps.connections.length > 0 || ps.items.length > 0)\n    const nextLockState: L.Property<\"locked\" | false> = L.view(showUnlock, (unlock): \"locked\" | false =>\n        unlock ? false : \"locked\",\n    )\n\n    function setLocked() {\n        const b = board.get()\n        if (!enabled.get()) return\n        const all = focusedItems.get()\n        const locked = nextLockState.get()\n        const items = all.items.map((item) => ({ id: item.id, locked }))\n        const connections = all.connections.map((connection) => ({ id: connection.id, locked }))\n        dispatch({ action: \"item.update\", boardId: b.id, items, connections })\n    }\n\n    return L.view(hasItems, nextLockState, (hasItems, nextState) => {\n        return hasItems\n            ? [\n                  <div className=\"icon-group lock\">\n                      <span\n                          className={L.view(enabled, (e) => (e ? \"icon\" : \"icon disabled\"))}\n                          title={nextState === \"locked\" ? \"Lock item(s)\" : \"Unlock item(s)\"}\n                          onClick={setLocked}\n                      >\n                          {nextState === \"locked\" ? <LockIcon /> : <UnlockIcon />}\n                      </span>\n                  </div>,\n              ]\n            : []\n    })\n}\n"
  },
  {
    "path": "frontend/src/board/contextmenu/shapes.tsx",
    "content": "import { h, HarmajaOutput } from \"harmaja\"\nimport * as _ from \"lodash\"\nimport * as L from \"lonna\"\nimport { Color, isShapedItem, Item, NoteShape, ShapedItem } from \"../../../../common/src/domain\"\nimport { ShapeDiamondIcon, ShapeRectIcon, ShapeRoundIcon, ShapeSquareIcon } from \"../../components/Icons\"\nimport { black, selectedColor } from \"../../components/UIColors\"\nimport { SubmenuProps } from \"./ContextMenuView\"\n\nconst shapes = {\n    square: ShapeSquareIcon,\n    round: ShapeRoundIcon,\n    rect: ShapeRectIcon,\n    diamond: ShapeDiamondIcon,\n}\n\ntype ShapeIcon = (c: Color, f?: Color) => HarmajaOutput\ntype ShapeIconAndId = { id: NoteShape; svg: ShapeIcon }\nconst shapeSymbols: ShapeIconAndId[] = Object.entries(shapes).map(([id, svg]) => ({ id: id as NoteShape, svg }))\n\nexport function getShapeIcon(item: Item): ShapeIcon {\n    return shapes[isShapedItem(item) ? item.shape || \"square\" : \"square\"]\n}\n\nexport function shapesSubMenu({ board, focusedItems, dispatch }: SubmenuProps) {\n    const shapedItems = L.view(focusedItems, (items) => items.items.filter(isShapedItem))\n    const anyShaped = L.view(shapedItems, (items) => items.length > 0)\n    const currentShape = L.view(shapedItems, (items) =>\n        _.uniq(items.map((item) => item.shape)).length > 1 ? undefined : items[0]?.shape,\n    )\n\n    return L.view(anyShaped, (anyShaped) => {\n        return !anyShaped\n            ? []\n            : [\n                  <div className=\"shapes icon-group\">\n                      {shapeSymbols.map((shape) => {\n                          return (\n                              <span className=\"icon\" onClick={changeShape(shape.id)}>\n                                  {L.view(\n                                      currentShape,\n                                      (s) => s === shape.id,\n                                      (selected) => shape.svg(selected ? selectedColor : black),\n                                  )}\n                              </span>\n                          )\n                      })}\n                  </div>,\n              ]\n    })\n\n    function changeShape(newShape: NoteShape) {\n        return () => {\n            const b = board.get()\n            const items = shapedItems.get()\n            const updated = items.map((item) => {\n                const maxDim = Math.max(item.width, item.height)\n                const dimensions =\n                    newShape === \"rect\"\n                        ? { width: maxDim * 1.2, height: maxDim / 1.2 }\n                        : { width: maxDim, height: maxDim }\n                return { id: item.id, shape: newShape, ...dimensions }\n            }) as ShapedItem[]\n            dispatch({ action: \"item.update\", boardId: b.id, items: updated })\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/contextmenu/textAlignments.tsx",
    "content": "import { HarmajaOutput, componentScope, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport {\n    Align,\n    Color,\n    HorizontalAlign,\n    Item,\n    ItemUpdate,\n    TextItem,\n    VerticalAlign,\n    getAlign,\n    getHorizontalAlign,\n    getVerticalAlign,\n    isTextItem,\n    setHorizontalAlign,\n    setVerticalAlign,\n} from \"../../../../common/src/domain\"\nimport {\n    TextAlignHorizontalCenterIcon,\n    TextAlignHorizontalLeftIcon,\n    TextAlignHorizontalRightIcon,\n    TextAlignVerticalBottomIcon,\n    TextAlignVerticalMiddleIcon,\n    TextAlignVerticalTopIcon,\n} from \"../../components/Icons\"\nimport { black, disabledColor } from \"../../components/UIColors\"\nimport { SubmenuProps } from \"./ContextMenuView\"\nimport { canChangeTextAlign } from \"../board-permissions\"\n\nexport function textAlignmentsMenu({ board, focusedItems, dispatch }: SubmenuProps) {\n    const textItems = L.view(focusedItems, (items) => items.items.filter(isTextItem))\n    const allText = L.view(focusedItems, textItems, (f, t) => f.items.length > 0 && t.length === f.items.length)\n\n    const currentHAlign = L.view(focusedItems, (f) => {\n        return getIfSame(f.items, (item) => (isTextItem(item) ? getHorizontalAlign(getAlign(item)) : null), \"left\")\n    })\n\n    const currentVAlign = L.view(focusedItems, (f) => {\n        return getIfSame(f.items, (item) => (isTextItem(item) ? getVerticalAlign(getAlign(item)) : null), \"top\")\n    })\n\n    function setAlign(modifyAlign: (i: TextItem) => ItemUpdate<TextItem>) {\n        focusedItems.get()\n        const b = board.get()\n        const updated = focusedItems\n            .get()\n            .items.filter(isTextItem)\n            .map((i) => modifyAlign(i))\n        dispatch({ action: \"item.update\", boardId: b.id, items: updated })\n    }\n\n    const enabled = L.view(textItems, (items) => items.some(canChangeTextAlign))\n\n    const className = enabled.pipe(L.map((e) => (e ? \"icon\" : \"icon disabled\")))\n\n    return L.view(allText, currentHAlign, currentVAlign, (all, ha, va) => {\n        return !all\n            ? []\n            : [\n                  <div className=\"icon-group text-align\">\n                      <span\n                          className={className}\n                          onClick={() => {\n                              const hAlign: HorizontalAlign = hAligns[(hAligns.indexOf(ha) + 1) % hAligns.length]\n                              setAlign((i) => setHorizontalAlign(i, hAlign))\n                          }}\n                          title=\"Horizontal align\"\n                      >\n                          {enabled.pipe(L.map((e) => horizontalIcons[ha](e ? black : disabledColor)))}\n                      </span>\n                  </div>,\n                  <div className=\"icon-group text-align\">\n                      <span\n                          className={className}\n                          onClick={() => {\n                              const vAlign: VerticalAlign = vAligns[(vAligns.indexOf(va) + 1) % vAligns.length]\n                              setAlign((i) => setVerticalAlign(i, vAlign))\n                          }}\n                          title=\"Vertical align\"\n                      >\n                          {enabled.pipe(L.map((e) => verticalIcons[va](e ? black : disabledColor)))}\n                      </span>\n                  </div>,\n              ]\n    })\n}\n\nconst horizontalIcons: Record<HorizontalAlign, (color: Color) => HarmajaOutput> = {\n    left: () => <TextAlignHorizontalLeftIcon />,\n    center: () => <TextAlignHorizontalCenterIcon />,\n    right: () => <TextAlignHorizontalRightIcon />,\n}\nconst verticalIcons: Record<VerticalAlign, (color: Color) => HarmajaOutput> = {\n    top: () => <TextAlignVerticalTopIcon />,\n    middle: () => <TextAlignVerticalMiddleIcon />,\n    bottom: () => <TextAlignVerticalBottomIcon />,\n}\nconst hAligns: HorizontalAlign[] = [\"left\", \"center\", \"right\"]\nconst vAligns: VerticalAlign[] = [\"top\", \"middle\", \"bottom\"]\n\nexport function getIfSame<I, P>(items: I[], get: (item: I) => P | null, defaultValue: P) {\n    let align: P | null = null\n    items.forEach((item) => {})\n    for (let i = 0; i < items.length; i++) {\n        const item = items[i]\n        const itemAlign = get(item)\n        if (align != null && itemAlign !== align) {\n            // If not all share the same, exit\n            align = null\n            break\n        }\n        align = itemAlign\n    }\n    return align ?? defaultValue\n}\n"
  },
  {
    "path": "frontend/src/board/contextmenu/textFormats.tsx",
    "content": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { CrdtEnabled, isTextItem } from \"../../../../common/src/domain\"\nimport { BoldIcon, ItalicIcon, UnderlineIcon } from \"../../components/Icons\"\nimport { canChangeTextFormat } from \"../board-permissions\"\nimport { SubmenuProps } from \"./ContextMenuView\"\n\nexport function textFormatsMenu({ board, focusedItems, dispatch }: SubmenuProps) {\n    const textItems = L.view(focusedItems, (items) =>\n        items.items.filter((i) => isTextItem(i) && i.crdt === CrdtEnabled),\n    )\n    const singleText = L.view(focusedItems, textItems, (f, t) => f.items.length === 1 && t.length === f.items.length)\n\n    const enabled = L.view(textItems, (items) => items.some(canChangeTextFormat))\n\n    const className = enabled.pipe(L.map((e) => (e ? \"icon\" : \"icon disabled\")))\n\n    return L.view(singleText, (singleText) => {\n        return !singleText\n            ? []\n            : [\n                  <div className=\"icon-group text-format\">\n                      <span\n                          className={className}\n                          onClick={() => {\n                              dispatch({\n                                  action: \"ui.text.format\",\n                                  itemIds: textItems.get().map((i) => i.id),\n                                  format: \"bold\",\n                              })\n                          }}\n                          title=\"Bold\"\n                      >\n                          <BoldIcon />\n                      </span>\n                      <span\n                          className={className}\n                          onClick={() => {\n                              dispatch({\n                                  action: \"ui.text.format\",\n                                  itemIds: textItems.get().map((i) => i.id),\n                                  format: \"italic\",\n                              })\n                          }}\n                          title=\"Italic\"\n                      >\n                          <ItalicIcon />\n                      </span>\n                      <span\n                          className={className}\n                          onClick={() => {\n                              dispatch({\n                                  action: \"ui.text.format\",\n                                  itemIds: textItems.get().map((i) => i.id),\n                                  format: \"underline\",\n                              })\n                          }}\n                          title=\"Underline\"\n                      >\n                          <UnderlineIcon />\n                      </span>\n                  </div>,\n                  ,\n              ]\n    })\n}\n"
  },
  {
    "path": "frontend/src/board/contrasting-color.ts",
    "content": "interface RGB {\n    b: number\n    g: number\n    r: number\n}\nfunction rgbToYIQ({ r, g, b }: RGB): number {\n    return (r * 299 + g * 587 + b * 114) / 1000\n}\nfunction hexToRgb(hex: string): RGB | undefined {\n    if (!hex || hex === undefined || hex === \"\") {\n        return undefined\n    }\n\n    const result: RegExpExecArray | null = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex)\n\n    return result\n        ? {\n              r: parseInt(result[1], 16),\n              g: parseInt(result[2], 16),\n              b: parseInt(result[3], 16),\n          }\n        : undefined\n}\n// Source: https://medium.com/better-programming/generate-contrasting-text-for-your-random-background-color-ac302dc87b4\nexport function contrastingColor(colorNameOrHex: string | undefined, threshold: number = 128): string {\n    if (colorNameOrHex === undefined) {\n        return \"#000\"\n    }\n\n    const rgb: RGB | undefined = hexToRgb(colorNameToHex(colorNameOrHex))\n\n    if (rgb === undefined) {\n        return \"#000\"\n    }\n\n    return rgbToYIQ(rgb) >= threshold ? \"#000\" : \"#fff\"\n}\n\n// Source: https://stackoverflow.com/questions/1573053/javascript-function-to-convert-color-names-to-hex-codes/24390910\nvar colours = {\n    aliceblue: \"#f0f8ff\",\n    antiquewhite: \"#faebd7\",\n    aqua: \"#00ffff\",\n    aquamarine: \"#7fffd4\",\n    azure: \"#f0ffff\",\n    beige: \"#f5f5dc\",\n    bisque: \"#ffe4c4\",\n    black: \"#000000\",\n    blanchedalmond: \"#ffebcd\",\n    blue: \"#0000ff\",\n    blueviolet: \"#8a2be2\",\n    brown: \"#a52a2a\",\n    burlywood: \"#deb887\",\n    cadetblue: \"#5f9ea0\",\n    chartreuse: \"#7fff00\",\n    chocolate: \"#d2691e\",\n    coral: \"#ff7f50\",\n    cornflowerblue: \"#6495ed\",\n    cornsilk: \"#fff8dc\",\n    crimson: \"#dc143c\",\n    cyan: \"#00ffff\",\n    darkblue: \"#00008b\",\n    darkcyan: \"#008b8b\",\n    darkgoldenrod: \"#b8860b\",\n    darkgray: \"#a9a9a9\",\n    darkgreen: \"#006400\",\n    darkkhaki: \"#bdb76b\",\n    darkmagenta: \"#8b008b\",\n    darkolivegreen: \"#556b2f\",\n    darkorange: \"#ff8c00\",\n    darkorchid: \"#9932cc\",\n    darkred: \"#8b0000\",\n    darksalmon: \"#e9967a\",\n    darkseagreen: \"#8fbc8f\",\n    darkslateblue: \"#483d8b\",\n    darkslategray: \"#2f4f4f\",\n    darkturquoise: \"#00ced1\",\n    darkviolet: \"#9400d3\",\n    deeppink: \"#ff1493\",\n    deepskyblue: \"#00bfff\",\n    dimgray: \"#696969\",\n    dodgerblue: \"#1e90ff\",\n    firebrick: \"#b22222\",\n    floralwhite: \"#fffaf0\",\n    forestgreen: \"#228b22\",\n    fuchsia: \"#ff00ff\",\n    gainsboro: \"#dcdcdc\",\n    ghostwhite: \"#f8f8ff\",\n    gold: \"#ffd700\",\n    goldenrod: \"#daa520\",\n    gray: \"#808080\",\n    green: \"#008000\",\n    greenyellow: \"#adff2f\",\n    honeydew: \"#f0fff0\",\n    hotpink: \"#ff69b4\",\n    \"indianred \": \"#cd5c5c\",\n    indigo: \"#4b0082\",\n    ivory: \"#fffff0\",\n    khaki: \"#f0e68c\",\n    lavender: \"#e6e6fa\",\n    lavenderblush: \"#fff0f5\",\n    lawngreen: \"#7cfc00\",\n    lemonchiffon: \"#fffacd\",\n    lightblue: \"#add8e6\",\n    lightcoral: \"#f08080\",\n    lightcyan: \"#e0ffff\",\n    lightgoldenrodyellow: \"#fafad2\",\n    lightgrey: \"#d3d3d3\",\n    lightgreen: \"#90ee90\",\n    lightpink: \"#ffb6c1\",\n    lightsalmon: \"#ffa07a\",\n    lightseagreen: \"#20b2aa\",\n    lightskyblue: \"#87cefa\",\n    lightslategray: \"#778899\",\n    lightsteelblue: \"#b0c4de\",\n    lightyellow: \"#ffffe0\",\n    lime: \"#00ff00\",\n    limegreen: \"#32cd32\",\n    linen: \"#faf0e6\",\n    magenta: \"#ff00ff\",\n    maroon: \"#800000\",\n    mediumaquamarine: \"#66cdaa\",\n    mediumblue: \"#0000cd\",\n    mediumorchid: \"#ba55d3\",\n    mediumpurple: \"#9370d8\",\n    mediumseagreen: \"#3cb371\",\n    mediumslateblue: \"#7b68ee\",\n    mediumspringgreen: \"#00fa9a\",\n    mediumturquoise: \"#48d1cc\",\n    mediumvioletred: \"#c71585\",\n    midnightblue: \"#191970\",\n    mintcream: \"#f5fffa\",\n    mistyrose: \"#ffe4e1\",\n    moccasin: \"#ffe4b5\",\n    navajowhite: \"#ffdead\",\n    navy: \"#000080\",\n    oldlace: \"#fdf5e6\",\n    olive: \"#808000\",\n    olivedrab: \"#6b8e23\",\n    orange: \"#ffa500\",\n    orangered: \"#ff4500\",\n    orchid: \"#da70d6\",\n    palegoldenrod: \"#eee8aa\",\n    palegreen: \"#98fb98\",\n    paleturquoise: \"#afeeee\",\n    palevioletred: \"#d87093\",\n    papayawhip: \"#ffefd5\",\n    peachpuff: \"#ffdab9\",\n    peru: \"#cd853f\",\n    pink: \"#ffc0cb\",\n    plum: \"#dda0dd\",\n    powderblue: \"#b0e0e6\",\n    purple: \"#800080\",\n    rebeccapurple: \"#663399\",\n    red: \"#ff0000\",\n    rosybrown: \"#bc8f8f\",\n    royalblue: \"#4169e1\",\n    saddlebrown: \"#8b4513\",\n    salmon: \"#fa8072\",\n    sandybrown: \"#f4a460\",\n    seagreen: \"#2e8b57\",\n    seashell: \"#fff5ee\",\n    sienna: \"#a0522d\",\n    silver: \"#c0c0c0\",\n    skyblue: \"#87ceeb\",\n    slateblue: \"#6a5acd\",\n    slategray: \"#708090\",\n    snow: \"#fffafa\",\n    springgreen: \"#00ff7f\",\n    steelblue: \"#4682b4\",\n    tan: \"#d2b48c\",\n    teal: \"#008080\",\n    thistle: \"#d8bfd8\",\n    tomato: \"#ff6347\",\n    turquoise: \"#40e0d0\",\n    violet: \"#ee82ee\",\n    wheat: \"#f5deb3\",\n    white: \"#ffffff\",\n    whitesmoke: \"#f5f5f5\",\n    yellow: \"#ffff00\",\n    yellowgreen: \"#9acd32\",\n} as Record<string, string>\n\nfunction colorNameToHex(c: string) {\n    if (colours[c]) {\n        return colours[c]\n    }\n    return c\n}\n"
  },
  {
    "path": "frontend/src/board/double-click.ts",
    "content": "import { componentScope } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { IS_TOUCHSCREEN, isSingleTouch } from \"./touchScreen\"\n\nexport function installDoubleClickHandler(action: (e: JSX.UIEvent) => void) {\n    if (IS_TOUCHSCREEN) {\n        let previousClick = 0\n        L.fromEvent<JSX.TouchEvent>(window, \"touchstart\")\n            .pipe(L.applyScope(componentScope()))\n            .forEach((e) => {\n                if (isSingleTouch(e)) {\n                    if (!!(e as any).PREVENT_DBL_CLICK) {\n                        return\n                    }\n                    const now = new Date().getTime()\n                    if (now - previousClick < 300) {\n                        action(e)\n                    }\n                    previousClick = now\n                }\n            })\n    } else {\n        L.fromEvent<JSX.MouseEvent>(window, \"dblclick\")\n            .pipe(L.applyScope(componentScope()))\n            .forEach((e) => {\n                e.preventDefault()\n                action(e)\n            })\n    }\n}\n\nexport function preventDoubleClick(e: JSX.TouchEvent) {\n    ;(e as any).PREVENT_DBL_CLICK = true\n}\n"
  },
  {
    "path": "frontend/src/board/header/BoardViewHeader.tsx",
    "content": "import { Fragment, h } from \"harmaja\"\nimport { getNavigator } from \"harmaja-router\"\nimport * as L from \"lonna\"\nimport { AccessLevel, Board, EventFromServer, UserSessionInfo } from \"../../../../common/src/domain\"\nimport { Routes, createBoardAndNavigate } from \"../../board-navigation\"\nimport { EditableSpan } from \"../../components/EditableSpan\"\nimport { Dispatch } from \"../../store/board-store\"\nimport { UserSessionState, defaultAccessPolicy } from \"../../store/user-session-store\"\nimport { BackToAllBoardsLink } from \"../toolbars/BackToAllBoardsLink\"\nimport { OtherUsersView } from \"./OtherUsersView\"\nimport { SharingModalDialog } from \"./SharingModalDialog\"\nimport { UserInfoView } from \"./UserInfoView\"\nimport { Rect } from \"../../../../common/src/geometry\"\nimport { CRDTStore } from \"../../store/crdt-store\"\nimport * as uuid from \"uuid\"\n\nexport function BoardViewHeader({\n    usersOnBoard,\n    board,\n    accessLevel,\n    sessionState,\n    dispatch,\n    modalContent,\n    eventsFromServer,\n    viewRect,\n    online,\n    crdtStore,\n}: {\n    usersOnBoard: L.Property<UserSessionInfo[]>\n    board: L.Property<Board>\n    accessLevel: L.Property<AccessLevel>\n    sessionState: L.Property<UserSessionState>\n    dispatch: Dispatch\n    modalContent: L.Atom<any>\n    eventsFromServer: L.EventStream<EventFromServer>\n    viewRect: L.Property<Rect>\n    online: L.Property<boolean>\n    crdtStore: CRDTStore\n}) {\n    const editingAtom = L.atom(false)\n    const nameAtom = L.atom(\n        L.view(board, (board) => board.name || \"\"),\n        (newName) => dispatch({ action: \"board.rename\", boardId: board.get()!.id, name: newName }),\n    )\n    const navigator = getNavigator<Routes>()\n    function makeCopy() {\n        const newBoard = {\n            id: uuid.v4(),\n            name: `${nameAtom.get()} copy`,\n            templateId: board.get()!.id,\n            accessPolicy: defaultAccessPolicy(sessionState.get(), false),\n        }\n        createBoardAndNavigate(newBoard, dispatch, navigator, eventsFromServer)\n    }\n\n    return (\n        <header>\n            <span className=\"logo-area\">\n                <BackToAllBoardsLink />\n            </span>\n            {L.view(\n                board,\n                (b) => !!b,\n                (b) =>\n                    b && (\n                        <>\n                            <span data-test=\"board-name\" id=\"board-info\">\n                                <span id=\"board-name\">\n                                    {L.view(accessLevel, (l) =>\n                                        l === \"read-only\" ? (\n                                            <span>\n                                                {nameAtom} <small>read-only</small>\n                                            </span>\n                                        ) : (\n                                            <EditableSpan value={nameAtom} editingThis={editingAtom} />\n                                        ),\n                                    )}\n                                </span>\n                                <ForkButton onClick={makeCopy} />\n                                <ShareButton\n                                    onClick={() =>\n                                        modalContent.set(\n                                            <SharingModalDialog\n                                                {...{\n                                                    board,\n                                                    sessionState,\n                                                    dismiss: () => modalContent.set(null),\n                                                    dispatch,\n                                                }}\n                                            />,\n                                        )\n                                    }\n                                />\n                            </span>\n                        </>\n                    ),\n            )}\n            <span className=\"right-panel\">\n                <OtherUsersView\n                    usersOnBoard={usersOnBoard}\n                    board={board}\n                    viewRect={viewRect}\n                    state={sessionState}\n                    dispatch={dispatch}\n                    online={online}\n                />\n                <UserInfoView\n                    state={sessionState}\n                    usersOnBoard={usersOnBoard}\n                    dispatch={dispatch}\n                    modalContent={modalContent}\n                />\n            </span>\n        </header>\n    )\n}\n\nconst ShareButton = ({ onClick }: { onClick: () => void }) => {\n    return (\n        <a className=\"board-button\" title=\"Sharing and permissions\" onClick={onClick}>\n            <svg viewBox=\"0 0 25 21\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                <title>Sharing and permissions</title>\n                <path\n                    d=\"M0.941645 6.11807L21.2054 1.47105C21.3809 1.4308 21.5161 1.62489 21.4174 1.77556L14.237 12.7407C14.2219 12.7638 14.2021 12.7836 14.1789 12.7987L8.77875 16.3188C8.63058 16.4154 8.43918 16.2863 8.47328 16.1127L9.6818 9.96216C9.70067 9.86617 9.64732 9.77062 9.55571 9.73631L0.916195 6.5003C0.73048 6.43074 0.748347 6.1624 0.941645 6.11807Z\"\n                    fill=\"white\"\n                    stroke=\"#0A5AF5\"\n                    stroke-width=\"1.2\"\n                    stroke-linecap=\"round\"\n                />\n                <path\n                    d=\"M15.1744 17.9751L9.85705 10.1208C9.79688 10.0319 9.81759 9.91137 9.90397 9.84767L21.1175 1.57863C21.277 1.46099 21.4921 1.62213 21.424 1.80829L15.5279 17.9317C15.4719 18.0849 15.2659 18.1102 15.1744 17.9751Z\"\n                    fill=\"white\"\n                    stroke=\"#0A5AF5\"\n                    stroke-width=\"1.2\"\n                    stroke-linecap=\"round\"\n                />\n            </svg>\n        </a>\n    )\n}\n\nconst ForkButton = ({ onClick }: { onClick: () => void }) => {\n    return (\n        <a className=\"board-button\" title=\"Make a copy\" onClick={onClick}>\n            <svg viewBox=\"0 0 22 21\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                <title>Make a copy</title>\n                <rect\n                    x=\"6.56582\"\n                    y=\"5.86245\"\n                    width=\"14.0013\"\n                    height=\"14.0013\"\n                    rx=\"1.4\"\n                    fill=\"#0A5AF5\"\n                    fill-opacity=\"0.1\"\n                    stroke=\"#0A5AF5\"\n                    stroke-width=\"1.2\"\n                />\n                <path\n                    d=\"M3.6805 15.7398H3.18848C2.08391 15.7398 1.18848 14.8444 1.18848 13.7398V3.18433C1.18848 2.07976 2.08391 1.18433 3.18848 1.18433H13.8317C14.9363 1.18433 15.8317 2.07976 15.8317 3.18433V3.76324\"\n                    stroke=\"#0A5AF5\"\n                    stroke-width=\"1.2\"\n                    stroke-linecap=\"round\"\n                />\n            </svg>\n        </a>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/header/OtherUsersView.tsx",
    "content": "import { ListView, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, UserSessionInfo } from \"../../../../common/src/domain\"\nimport { Dispatch } from \"../../store/board-store\"\nimport { UserSessionState } from \"../../store/user-session-store\"\nimport { Rect } from \"../../../../common/src/geometry\"\nimport { assertNotNull } from \"../../../../common/src/assertNotNull\"\n\ntype OtherUsersViewProps = {\n    usersOnBoard: L.Property<UserSessionInfo[]>\n    dispatch: Dispatch\n    state: L.Property<UserSessionState>\n    board: L.Property<Board>\n    viewRect: L.Property<Rect>\n    online: L.Property<boolean>\n}\n\nexport const OtherUsersView = ({ usersOnBoard, dispatch, state, online, board, viewRect }: OtherUsersViewProps) => {\n    const status = L.view(online, usersOnBoard, (online, users) =>\n        online ? (users.length > 1 ? \"online-with-others\" : \"online-alone\") : \"offline\",\n    )\n    return L.view(status, (status) => {\n        if (status === \"offline\") {\n            return (\n                <div\n                    className=\"offline-status\"\n                    title=\"Keep on working! Your work will be synchronized with others', once we are connected to the server again.\"\n                >\n                    Offline\n                </div>\n            )\n        } else if (status === \"online-alone\") {\n            return null\n        }\n        return (\n            <div className=\"other-users\">\n                {L.view(usersOnBoard, (u) => u.length)} users\n                <div className=\"pop-up\">\n                    <ul>\n                        <li>\n                            <a\n                                onClick={() =>\n                                    dispatch({\n                                        action: \"user.bringAllToMe\",\n                                        boardId: board.get().id,\n                                        sessionId: assertNotNull(state.get().sessionId),\n                                        viewRect: viewRect.get(),\n                                        nickname: assertNotNull(state.get().nickname),\n                                    })\n                                }\n                            >\n                                Bring all to me\n                            </a>\n                        </li>\n                        <ListView\n                            observable={L.view(usersOnBoard, (users) =>\n                                users.slice().sort((a, b) => a.nickname.localeCompare(b.nickname)),\n                            )}\n                            renderItem={(u) => (\n                                <li className=\"user\">\n                                    {u.nickname}\n                                    <span className=\"youlink\">\n                                        {u.sessionId === state.get().sessionId ? \" (you)\" : \"\"}\n                                    </span>\n                                </li>\n                            )}\n                        />\n                    </ul>\n                </div>\n            </div>\n        )\n    })\n}\n"
  },
  {
    "path": "frontend/src/board/header/SharingModalDialog.tsx",
    "content": "import { Fragment, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, checkBoardAccess } from \"../../../../common/src/domain\"\nimport { BoardAccessPolicyEditor } from \"../../components/BoardAccessPolicyEditor\"\nimport { Dispatch, sessionState2UserInfo } from \"../../store/board-store\"\nimport { UserSessionState } from \"../../store/user-session-store\"\n\nexport const SharingModalDialog = ({\n    board,\n    sessionState,\n    dismiss,\n    dispatch,\n}: {\n    board: L.Property<Board>\n    sessionState: L.Property<UserSessionState>\n    dismiss: () => void\n    dispatch: Dispatch\n}) => {\n    const originalAccessPolicy = board.get().accessPolicy\n    const accessPolicy = L.atom(originalAccessPolicy)\n\n    const copied = L.atom(false)\n    function copyToClipboard() {\n        navigator.clipboard.writeText(document.location.toString())\n        copied.set(true)\n        setTimeout(() => copied.set(false), 3000)\n    }\n    function saveChanges() {\n        dispatch({ action: \"board.setAccessPolicy\", boardId: board.get().id, accessPolicy: accessPolicy.get()! })\n        dismiss()\n    }\n    const adminAccess = L.view(\n        sessionState,\n        (s) => checkBoardAccess(originalAccessPolicy, sessionState2UserInfo(s)) === \"admin\",\n    )\n\n    return (\n        <span className=\"sharing\">\n            <h2>Sharing</h2>\n            <button onClick={copyToClipboard}>Copy board link</button>\n            {L.view(copied, (c) => (c ? <span className=\"copied\">Copied to clipboard.</span> : null))}\n            {L.view(sessionState, adminAccess, (s, admin) =>\n                s.status === \"logged-in\" && admin ? (\n                    <>\n                        <h2>Manage board permissions</h2>\n                        <BoardAccessPolicyEditor {...{ accessPolicy, user: s }} />\n                        <p>\n                            <button\n                                onClick={saveChanges}\n                                disabled={L.view(accessPolicy, (ap) => ap === originalAccessPolicy)}\n                            >\n                                Save changes\n                            </button>\n                        </p>\n                    </>\n                ) : originalAccessPolicy ? (\n                    <>\n                        <h2>Board permissions</h2>\n                        <p>You don't have the privileges to change board permissions</p>\n                    </>\n                ) : (\n                    <>\n                        <h2>Board permissions</h2>\n                        <p>\n                            Anonymous boards are accessible to anyone with the link. To control board permissions,\n                            create a new board when logged in.\n                        </p>\n                    </>\n                ),\n            )}\n        </span>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/header/UserInfoModal.tsx",
    "content": "import { Fragment, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { TextInput } from \"../../components/components\"\nimport { UserIcon } from \"../../components/Icons\"\nimport { signIn, signOut } from \"../../google-auth\"\nimport { Dispatch } from \"../../store/board-store\"\nimport { canLogin, LoggingInServer, UserSessionState } from \"../../store/user-session-store\"\n\nexport const UserInfoModal = ({\n    state,\n    dispatch,\n    dismiss,\n}: {\n    state: L.Property<UserSessionState>\n    dispatch: Dispatch\n    dismiss: () => void\n}) => {\n    const pictureURL = L.view(state, (s) => (s.status === \"logged-in\" ? s.picture : undefined))\n    return (\n        <span className=\"user-info\">\n            <h2>Welcome to OurBoard</h2>\n            {L.view(\n                state,\n                (s) => s.status,\n                (status) => {\n                    switch (status) {\n                        case \"logging-in-server\":\n                            return null\n                        case \"logged-in\":\n                            return (\n                                <div className=\"logged-in\">\n                                    <p>\n                                        You're signed in as{\" \"}\n                                        <span className=\"name\">\n                                            {L.view(state, (s) => (s as LoggingInServer).name)}\n                                        </span>{\" \"}\n                                        ({L.view(state, (s) => (s as LoggingInServer).email)}).\n                                    </p>\n                                    <p>\n                                        <a className=\"login\" onClick={signOut}>\n                                            Sign out\n                                        </a>{\" \"}\n                                        to access the board anonymously.\n                                    </p>\n                                </div>\n                            )\n                        default:\n                            return (\n                                <div className=\"anonymous\">\n                                    <p className=\"nickname\">\n                                        <span>Select nickname to be shown to others</span>\n                                        <NicknameEditor {...{ state, dispatch }} />\n                                    </p>\n                                    {L.view(\n                                        state,\n                                        canLogin,\n                                        (show) =>\n                                            show && (\n                                                <p className=\"sign-in\">\n                                                    Or{\" \"}\n                                                    <a className=\"login\" onClick={signIn}>\n                                                        sign in\n                                                    </a>{\" \"}\n                                                    using your Google account.\n                                                </p>\n                                            ),\n                                    )}\n                                </div>\n                            )\n                    }\n                },\n            )}\n            <p>\n                <button onClick={dismiss}>Done</button>\n            </p>\n        </span>\n    )\n}\n\nconst NicknameEditor = ({ state, dispatch }: { state: L.Property<UserSessionState>; dispatch: Dispatch }) => {\n    const nicknameAtom = L.atom(L.view(state, \"nickname\"), (nickname) => {\n        if (nickname === undefined) throw Error(\"Cannot set nickname to undefined\")\n        dispatch({ action: \"nickname.set\", nickname })\n    })\n\n    return <TextInput value={nicknameAtom} />\n}\n"
  },
  {
    "path": "frontend/src/board/header/UserInfoView.tsx",
    "content": "import { componentScope, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { UserSessionInfo } from \"../../../../common/src/domain\"\nimport { UserIcon } from \"../../components/Icons\"\nimport { BoardState, Dispatch } from \"../../store/board-store\"\nimport { UserSessionState } from \"../../store/user-session-store\"\nimport { UserInfoModal } from \"./UserInfoModal\"\n\nexport const UserInfoView = ({\n    state,\n    dispatch,\n    usersOnBoard,\n    modalContent,\n}: {\n    state: L.Property<UserSessionState>\n    usersOnBoard: L.Property<UserSessionInfo[]>\n    dispatch: Dispatch\n    modalContent: L.Atom<any>\n}) => {\n    const pictureURL = L.view(state, (s) => (s.status === \"logged-in\" ? s.picture : undefined))\n    usersOnBoard\n        .pipe(\n            L.map((users) => users.length),\n            L.changes,\n            L.filter((l) => l > 1),\n            L.takeUntil(L.later(5000, null)),\n            L.take(1),\n            L.applyScope(componentScope()),\n        )\n        .forEach(() => {\n            if (!state.get().nicknameSetByUser) {\n                showDialog()\n            }\n        })\n\n    function dismiss() {\n        modalContent.set(null)\n    }\n    function showDialog() {\n        modalContent.set(<UserInfoModal {...{ dispatch, state, dismiss }} />)\n    }\n\n    return (\n        <span\n            className={L.view(\n                state,\n                (s) => s.status,\n                (s) => `user-info ${s}`,\n            )}\n        >\n            <span className=\"icon\" onClick={showDialog}>\n                {L.view(pictureURL, (p) => (p ? <img src={p} /> : <UserIcon />))}\n            </span>\n        </span>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/image-upload.ts",
    "content": "import * as L from \"lonna\"\nimport { Item, newImage, newVideo } from \"../../../common/src/domain\"\nimport { AssetStore, AssetURL } from \"../store/asset-store\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { BoardFocus } from \"./board-focus\"\n\nexport function imageDropHandler(\n    boardElement: L.Atom<HTMLElement | null>,\n    assets: AssetStore,\n    focus: L.Atom<BoardFocus>,\n    uploadImageFile: (file: File) => Promise<void>,\n) {\n    boardElement.forEach((el) => {\n        if (el) {\n            function preventDefaults(e: any) {\n                e.preventDefault()\n                e.stopPropagation()\n            }\n            ;[\"dragenter\", \"dragover\", \"dragleave\", \"drop\"].forEach((eventName) => {\n                el.addEventListener(eventName, preventDefaults, false)\n            })\n\n            el.addEventListener(\"drop\", handleDrop, false)\n            async function handleDrop(e: DragEvent) {\n                if (focus.get().status === \"dragging\") {\n                    return // was dragging an item\n                }\n\n                const url = e.dataTransfer?.getData(\"URL\")\n                if (url) {\n                    // Try direct fetch first, and on CORS error fall back to letting the server request it\n                    const res = await fetch(url).catch(() => assets.getExternalAssetAsBytes(url))\n                    const blob = await res.blob()\n                    await uploadImageFile(blob as any)\n                } else {\n                    let dt = e.dataTransfer\n                    let files = dt!.files\n                    if (files.length === 0) {\n                        return\n                    }\n                    if (files.length != 1) {\n                        throw Error(\"Unexpected number of files: \" + files.length)\n                    }\n                    const file = files[0]\n                    await uploadImageFile(file)\n                }\n            }\n        }\n    })\n}\n\nexport type ImageUploadFunction = (file: File) => Promise<void>\nexport function imageUploadHandler(\n    assets: AssetStore,\n    coordinateHelper: BoardCoordinateHelper,\n    onAdd: (item: Item) => void,\n    onURL: (id: string, url: AssetURL) => void,\n): ImageUploadFunction {\n    return async (file: File): Promise<void> => {\n        const info = await imageDimensions(file)\n        if (!info) {\n            console.log(\"File is not an image\")\n        } else if (info.type === \"image\") {\n            const [assetId, urlPromise] = await assets.uploadAsset(file)\n            const { width, height } = info\n            const maxWidth = 10\n            const w = Math.min(width, maxWidth)\n            const h = (height * w) / width\n            const { x, y } = coordinateHelper.currentBoardCoordinates.get()\n            const image = newImage(assetId, x, y, w, h)\n            onAdd(image)\n            const finalUrl = await urlPromise\n            onURL(assetId, finalUrl)\n        }\n    }\n}\n\ntype ImageInfo = { type: \"image\"; width: number; height: number }\n\nfunction imageDimensions(file: File): Promise<ImageInfo | null> {\n    return new Promise((resolve, reject) => {\n        const img = new Image()\n\n        // the following handler will fire after the successful loading of the image\n        img.onload = () => {\n            const { naturalWidth: width, naturalHeight: height } = img\n            resolve({ type: \"image\", width, height })\n        }\n\n        // and this handler will fire if there was an error with the image (like if it's not really an image or a corrupted one)\n        img.onerror = () => {\n            resolve(null)\n        }\n\n        img.src = URL.createObjectURL(file)\n    })\n}\n"
  },
  {
    "path": "frontend/src/board/item-connect.ts",
    "content": "import _, { isEqual } from \"lodash\"\nimport * as L from \"lonna\"\nimport { globalScope } from \"lonna\"\nimport * as uuid from \"uuid\"\nimport { rerouteByNewControlPoints, rerouteConnection, resolveEndpoint } from \"../../../common/src/connection-utils\"\nimport {\n    Board,\n    Connection,\n    ConnectionEndPoint,\n    getEndPointItemId,\n    Id,\n    isContainedBy,\n    isItem,\n    isItemEndPoint,\n    isPoint,\n    Item,\n    Point,\n} from \"../../../common/src/domain\"\nimport { Dispatch } from \"../store/board-store\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { BoardFocus, noFocus } from \"./board-focus\"\nimport { Coordinates, centerPoint, containedBy } from \"../../../common/src/geometry\"\nimport { ToolController } from \"./tool-selection\"\nimport { emptySet } from \"../../../common/src/sets\"\nimport { IS_TOUCHSCREEN, onSingleTouch } from \"./touchScreen\"\n\nexport const DND_GHOST_HIDING_IMAGE = new Image()\n// https://png-pixel.com/\nDND_GHOST_HIDING_IMAGE.src =\n    \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==\"\n\nlet currentConnectionHandler = L.atom<ConnectionHandler | null>(null)\n\nexport function startConnecting(\n    board: L.Property<Board>,\n    coordinateHelper: BoardCoordinateHelper,\n    latestConnection: L.Property<Connection | null>,\n    dispatch: Dispatch,\n    toolController: ToolController,\n    focus: L.Atom<BoardFocus>,\n    from: Item | Point,\n) {\n    const h = currentConnectionHandler.get()\n    if (h) {\n        endConnection()\n    } else {\n        const h = newConnectionCreator(board, focus, latestConnection, dispatch)\n        currentConnectionHandler.set(h)\n        focus.set({ status: \"connection-adding\" })\n        const toWatch = [currentConnectionHandler, toolController.tool, focus] as L.Property<any>[]\n        const stop = L.merge(toWatch.map((p) => p.pipe(L.changes))).pipe(L.take(1, globalScope))\n        const action = toolController.tool.get() === \"line\" ? \"line\" : \"connect\"\n\n        h.whileDragging(from, coordinateHelper.currentBoardCoordinates.get(), action)\n        coordinateHelper.currentBoardCoordinates.pipe(L.takeUntil(stop)).forEach((pos) => {\n            h.whileDragging(from, pos, action)\n        })\n        stop.forEach(endConnection)\n    }\n\n    function endConnection() {\n        const h = currentConnectionHandler.get()\n        if (h) {\n            h.endDrag()\n            toolController.useDefaultTool()\n            currentConnectionHandler.set(null)\n        }\n    }\n}\n\ntype ConnectionHandler = ReturnType<typeof newConnectionCreator>\n\nexport function newConnectionCreator(\n    board: L.Property<Board>,\n    focus: L.Atom<BoardFocus>,\n    latestConnection: L.Property<Connection | null>,\n    dispatch: Dispatch,\n) {\n    let localConnection: Connection | null = null\n\n    function whileDragging(from: Item | Point, currentBoardCoords: Point, action: \"connect\" | \"line\") {\n        const b = board.get()\n        const boardId = b.id\n        const startPoint: ConnectionEndPoint = isItem(from) ? from.id : from\n        const target = findTarget(b, startPoint, currentBoardCoords, localConnection, getFindTargetOptions(action))\n\n        if (target === null) {\n            if (localConnection !== null) {\n                // Remove current connection, because connect-to-self is not allowed at least for now\n                if (!IS_TOUCHSCREEN)\n                    dispatch({ action: \"connection.delete\", boardId, connectionIds: [localConnection.id] })\n                localConnection = null\n            }\n        } else {\n            if (localConnection === null) {\n                // Start new connection\n                localConnection = newConnection(startPoint, target, action)\n                if (!IS_TOUCHSCREEN) dispatch({ action: \"connection.add\", boardId, connections: [localConnection] })\n            } else {\n                // Change current connection endpoint\n                // console.log({ item, midpoint, to: targetExistingItem ?? currentPos })\n\n                localConnection = rerouteConnection(\n                    {\n                        ...localConnection,\n                        to: target,\n                    },\n                    b,\n                )\n\n                if (!IS_TOUCHSCREEN)\n                    dispatch({ action: \"connection.modify\", boardId: b.id, connections: [localConnection] })\n            }\n        }\n\n        function newConnection(from: ConnectionEndPoint, target: Id | Point, action: \"connect\" | \"line\"): Connection {\n            const l = latestConnection.get()\n\n            return rerouteConnection(\n                {\n                    id: uuid.v4(),\n                    from: from,\n                    controlPoints: l && l.controlPoints.length === 0 ? [] : [{ x: 0, y: 0 }],\n                    to: target,\n                    action,\n                    locked: false,\n                    ...(action === \"connect\"\n                        ? {\n                              fromStyle: (l && l.fromStyle) ?? \"none\",\n                              toStyle: (l && l.toStyle) ?? \"arrow\",\n                              pointStyle: (l && l.pointStyle) ?? \"black-dot\",\n                          }\n                        : {\n                              fromStyle: \"none\",\n                              toStyle: \"none\",\n                              pointStyle: \"none\",\n                          }),\n                },\n                b,\n            )\n        }\n    }\n\n    const endDrag = () => {\n        if (localConnection) {\n            const addedConnection = localConnection\n            localConnection = null\n            if (IS_TOUCHSCREEN) {\n                dispatch({ action: \"connection.add\", boardId: board.get().id, connections: [addedConnection] })\n            }\n            focus.set({ status: \"selected\", itemIds: emptySet(), connectionIds: new Set(addedConnection.id) })\n        } else {\n            focus.set(noFocus)\n        }\n    }\n\n    return {\n        endDrag,\n        whileDragging,\n        getCurrentConnection: () => localConnection,\n    }\n}\n\nfunction shouldPreventAttach(e: DragEvent) {\n    return e.shiftKey || e.altKey || e.ctrlKey || e.metaKey\n}\n\nexport function existingConnectionHandler(\n    endNode: HTMLElement,\n    connectionId: string,\n    type: \"from\" | \"to\" | \"control\",\n    coordinateHelper: BoardCoordinateHelper,\n    board: L.Property<Board>,\n    dispatch: Dispatch,\n) {\n    endNode.addEventListener(\"drag\", (e) => e.stopPropagation())\n    endNode.addEventListener(\"drag\", (e) => modifyConnection(shouldPreventAttach(e)))\n\n    endNode.addEventListener(\"touchmove\", (e: TouchEvent) => {\n        onSingleTouch(e, (touch) => {\n            e.preventDefault()\n            e.stopPropagation()\n            coordinateHelper.currentPageCoordinates.set({ x: touch.pageX, y: touch.pageY })\n            modifyConnection(false)\n        })\n    })\n\n    let prevCoords: Coordinates = coordinateHelper.currentBoardCoordinates.get()\n\n    function modifyConnection(preventAttach: boolean) {\n        const coords = coordinateHelper.currentBoardCoordinates.get()\n        if (isEqual(coords, prevCoords)) {\n            return\n        }\n        prevCoords = coords\n        const b = board.get()\n        const connection = b.connections.find((c) => c.id === connectionId)!\n        const options = getFindTargetOptions(connection.action, preventAttach)\n        if (type === \"to\") {\n            const target = findTarget(b, connection.from, coords, connection, options)\n            if (target !== null) {\n                const to = target\n                dispatch({\n                    action: \"connection.modify\",\n                    boardId: b.id,\n                    connections: [rerouteConnection({ ...connection, to }, b)],\n                })\n            }\n        } else if (type === \"from\") {\n            const target = findTarget(b, connection.to, coords, connection, options)\n            if (target != null) {\n                const from = target\n                dispatch({\n                    action: \"connection.modify\",\n                    boardId: b.id,\n                    connections: [rerouteConnection({ ...connection, from }, b)],\n                })\n            }\n        } else {\n            dispatch({\n                action: \"connection.modify\",\n                boardId: b.id,\n                connections: [rerouteByNewControlPoints(connection, [coords], b)],\n            })\n        }\n    }\n}\n\nfunction getFindTargetOptions(action: \"line\" | \"connect\", preventAttach = false): FindTargetOptions {\n    return {\n        allowConnect: action === \"connect\" && !preventAttach,\n        allowSnap: !preventAttach,\n    }\n}\n\ntype FindTargetOptions = { allowConnect: boolean; allowSnap: boolean }\nfunction findTarget(\n    b: Board,\n    from: Item | ConnectionEndPoint,\n    currentPos: Point,\n    currentConnection: Connection | null,\n    options: FindTargetOptions,\n): Id | Point | null {\n    const items = b.items\n    const resolvedFromPoint = resolveEndpoint(from, items)\n    const fromItem = isItem(resolvedFromPoint) ? resolvedFromPoint : null\n\n    if (fromItem && containedBy(currentPos, fromItem)) {\n        // Target point inside fromItem => not acceptable\n        return null\n    }\n\n    const targetItem =\n        options.allowConnect &&\n        Object.values(items)\n            .filter((i) => !i.hidden)\n            .filter((i) => containedBy({ ...currentPos, width: 0, height: 0 }, i)) // match coordinates\n            .filter((i) => isConnectionAttachmentPoint(currentPos, i))\n            .filter((i) =>\n                isItem(resolvedFromPoint)\n                    ? !isConnected(b, i, resolvedFromPoint, currentConnection)\n                    : !containedBy(resolvedFromPoint, i),\n            )\n            .sort((a, b) => (isContainedBy(items, a)(b) ? 1 : -1)) // most innermost first (containers last)\n            .find((i) => !fromItem || !isContainedBy(items, i)(fromItem)) // does not contain the \"from\" item\n\n    if (targetItem) return targetItem\n    if (!isPoint(from)) return currentPos\n    const xDiff = Math.abs(currentPos.x - from.x)\n    const yDiff = Math.abs(currentPos.y - from.y)\n    if (xDiff === 0 || yDiff === 0) return currentPos\n    const threshold = 0.02\n    if (xDiff / yDiff < threshold) return { x: from.x, y: currentPos.y }\n    if (yDiff / xDiff < threshold) return { x: currentPos.x, y: from.y }\n    return currentPos\n}\n\nfunction isConnected(b: Board, x: Item, y: Item, connectionToIgnore: Connection | null) {\n    return b.connections.some((c) => connectionToIgnore != c && isConnectionRelated(x, c) && isConnectionRelated(y, c))\n}\n\nfunction isConnectionRelated(i: Item, c: Connection) {\n    return isEndPointRelated(i, c.from) || isEndPointRelated(i, c.to)\n}\n\nfunction isEndPointRelated(i: Item, c: ConnectionEndPoint) {\n    return isItemEndPoint(c) && getEndPointItemId(c) === i.id\n}\n\nexport function isConnectionAttachmentPoint(point: Point, item: Item) {\n    if (item.type !== \"container\") return true\n    const center = centerPoint(item)\n    const factor = 0.9\n    return (\n        Math.abs(point.x - center.x) > (item.width / 2) * factor ||\n        Math.abs(point.y - center.y) > (item.height / 2) * factor\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/item-create.ts",
    "content": "import * as L from \"lonna\"\nimport { Board, Item, newContainer, newSimilarNote, newText, Note } from \"../../../common/src/domain\"\nimport { BoardFocus, getSelectedItem } from \"./board-focus\"\nimport { installDoubleClickHandler } from \"./double-click\"\nimport { installKeyboardShortcut, plainKey } from \"./keyboard-shortcuts\"\n\nexport function itemCreateHandler(\n    board: L.Property<Board>,\n    focus: L.Property<BoardFocus>,\n    latestNote: L.Property<Note>,\n    boardElement: L.Property<HTMLElement | null>,\n    onAdd: (item: Item) => void,\n) {\n    installKeyboardShortcut(plainKey(\"n\"), () => onAdd(newSimilarNote(latestNote.get())))\n    installKeyboardShortcut(plainKey(\"a\"), () => onAdd(newContainer(board.get().crdt)))\n    installKeyboardShortcut(plainKey(\"t\"), () => onAdd(newText(board.get().crdt)))\n\n    installDoubleClickHandler((e) => {\n        shouldCreateOnDblClick(e) && onAdd(newSimilarNote(latestNote.get()))\n    })\n\n    function shouldCreateOnDblClick(event: JSX.UIEvent) {\n        if (event.target === boardElement.get()! || boardElement.get()!.contains(event.target as Node)) {\n            const f = focus.get()\n            const selectedElement = getSelectedItem(board.get())(focus.get())\n            if (f.status === \"none\" || (selectedElement && selectedElement.type === \"container\")) {\n                return true\n            }\n        }\n        return false\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/item-cut-copy-paste.ts",
    "content": "import _ from \"lodash\"\nimport * as L from \"lonna\"\nimport * as uuid from \"uuid\"\nimport { DEFAULT_NOTE_COLOR } from \"../../../common/src/colors\"\nimport { connectionRect, resolveEndpoint } from \"../../../common/src/connection-utils\"\nimport {\n    BOARD_ITEM_BORDER_MARGIN,\n    Board,\n    Connection,\n    ConnectionEndPoint,\n    Id,\n    Item,\n    Point,\n    findItemsRecursively,\n    getEndPointItemId,\n    isItemEndPoint,\n    newNote,\n    newText,\n} from \"../../../common/src/domain\"\nimport * as G from \"../../../common/src/geometry\"\nimport { emptySet } from \"../../../common/src/sets\"\nimport { sanitizeHTML } from \"../components/sanitizeHTML\"\nimport { Dispatch } from \"../store/board-store\"\nimport { CRDTStore } from \"../store/crdt-store\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { BoardFocus, getSelectedConnectionIds, getSelectedItemIds } from \"./board-focus\"\n\nconst CLIPBOARD_EVENTS = [\"cut\", \"copy\", \"paste\"] as const\n\nexport type ItemsAndConnections = {\n    items: Item[]\n    connections: Connection[]\n}\n\nexport function augmentWithCRDT(\n    boardId: Id,\n    itemsAndConnections: ItemsAndConnections,\n    crdtStore: CRDTStore,\n): ItemsAndConnections {\n    return {\n        ...itemsAndConnections,\n        items: crdtStore.augmentItems(boardId, itemsAndConnections.items),\n    }\n}\n\nexport function findSelectedItemsAndConnections(currentFocus: BoardFocus, currentBoard: Board): ItemsAndConnections {\n    const selectedItemIds = getSelectedItemIds(currentFocus)\n    const selectedConnectionIds = getSelectedConnectionIds(currentFocus)\n    const items = findItemsRecursively([...selectedItemIds], currentBoard)\n    const recursiveItemIds = new Set(items.map((i) => i.id))\n    const connections = currentBoard.connections\n        .filter((c) => {\n            if (selectedConnectionIds.has(c.id)) {\n                return true\n            }\n            // Include connections between these items and connections that have one end\n            // in these items and the other end not connected.\n            const ids = connectedIds(c)\n            if (ids.length > 0 && !ids.some((id) => !recursiveItemIds.has(id))) {\n                return true\n            }\n            if (c.containerId && recursiveItemIds.has(c.containerId)) {\n                return true\n            }\n        })\n        .map((c) => ({\n            ...c,\n            from: detachEndPointIfItemNotFound(c.from, recursiveItemIds, currentBoard),\n            to: detachEndPointIfItemNotFound(c.to, recursiveItemIds, currentBoard),\n        }))\n    return { items, connections }\n}\n\nfunction detachEndPointIfItemNotFound(ep: ConnectionEndPoint, itemIds: Set<Id>, currentBoard: Board) {\n    if (isItemEndPoint(ep) && !itemIds.has(getEndPointItemId(ep))) {\n        const resolved = resolveEndpoint(ep, currentBoard)\n        return Point(resolved.x, resolved.y)\n    }\n    return ep\n}\n\nfunction connectedIds(connection: Connection) {\n    const endpoints = [connection.to, connection.from]\n    return endpoints.flatMap((ep) => (isItemEndPoint(ep) ? [getEndPointItemId(ep)] : []))\n}\n\nexport function makeCopies(\n    itemsAndConnections: ItemsAndConnections,\n    xDiff: number,\n    yDiff: number,\n): { toCreate: Item[]; toSelect: Item[]; connections: Connection[] } {\n    const items = itemsAndConnections.items\n    const ids = new Set(items.map((i) => i.id))\n    const contained = items.filter((i) => !!i.containerId && ids.has(i.containerId))\n    const notContained = items.filter((i) => !contained.some((c) => c.id === i.id))\n    const oldToNewId: Record<Id, Id> = {}\n    let toCreate: Item[] = []\n    // As a side effect makeCopy adds items to toCreate\n    const toSelect = notContained.map(makeCopy)\n    // As a result, the top-level (notContained) copied items will be selected\n    const connections = itemsAndConnections.connections.map((c) => {\n        return {\n            ...c,\n            from: translateEndpoint(c.from),\n            to: translateEndpoint(c.to),\n            controlPoints: c.controlPoints.map(translateEndpoint) as Point[],\n            id: uuid.v4(),\n            containerId: c.containerId && oldToNewId[c.containerId],\n        }\n    })\n\n    return { toCreate, toSelect, connections }\n\n    function translateEndpoint(endPoint: ConnectionEndPoint) {\n        if (isItemEndPoint(endPoint)) {\n            const newId = oldToNewId[getEndPointItemId(endPoint)]\n            if (!newId) {\n                console.warn(`Target item ${endPoint} not found from pasted contents, assuming it exists on board`)\n                return endPoint\n            }\n            return newId\n        } else {\n            return G.add(endPoint, { x: xDiff, y: yDiff })\n        }\n    }\n\n    function makeCopy(i: Item): Item {\n        const containerId = i.id\n        const newContainer = flatCopy(i)\n        toCreate.push(newContainer)\n        contained\n            .filter((ctd) => ctd.containerId === containerId)\n            .forEach((ctd) => {\n                makeCopy({ ...ctd, containerId: newContainer.id })\n            })\n        return newContainer\n    }\n\n    function flatCopy(i: Item) {\n        const newId = uuid.v4()\n        oldToNewId[i.id] = newId\n        return { ...i, id: newId, x: i.x + xDiff, y: i.y + yDiff }\n    }\n}\n\nexport function cutCopyPasteHandler(\n    board: L.Property<Board>,\n    crdtStore: CRDTStore,\n    focus: L.Atom<BoardFocus>,\n    coordinateHelper: BoardCoordinateHelper,\n    dispatch: Dispatch,\n    uploadImageFile: (file: File) => Promise<void>,\n) {\n    const clipboardEventHandler = (e: ClipboardEvent) => {\n        const currentFocus = focus.get()\n        const currentBoard = board.get()\n        switch (e.type) {\n            case \"cut\": {\n                if (currentFocus.status !== \"selected\") return\n                const clipboard = augmentWithCRDT(\n                    board.get().id,\n                    findSelectedItemsAndConnections(currentFocus, currentBoard),\n                    crdtStore,\n                )\n                dispatch({\n                    action: \"item.delete\",\n                    boardId: currentBoard.id,\n                    itemIds: clipboard.items.map((i) => i.id),\n                    connectionIds: [...getSelectedConnectionIds(currentFocus)],\n                })\n                e.clipboardData!.setData(\"application/rboard\", JSON.stringify(clipboard))\n                e.preventDefault()\n                break\n            }\n            case \"copy\": {\n                if (currentFocus.status !== \"selected\") return\n                console.log(\"Copying to clipboard\", currentBoard)\n                const clipboard = augmentWithCRDT(\n                    board.get().id,\n                    findSelectedItemsAndConnections(currentFocus, currentBoard),\n                    crdtStore,\n                )\n                e.clipboardData!.setData(\"application/rboard\", JSON.stringify(clipboard))\n                e.preventDefault()\n                break\n            }\n            case \"paste\": {\n                if (e.clipboardData) {\n                    const imageFile = [...e.clipboardData.files].find((file) => file.type.startsWith(\"image/\"))\n                    if (imageFile) {\n                        uploadImageFile(imageFile)\n                        return\n                    }\n                }\n                if (currentFocus.status === \"editing\") return\n                const rboardData = e.clipboardData?.getData(\"application/rboard\")\n                if (!rboardData) {\n                    const html = e.clipboardData?.getData(\"text/html\") || e.clipboardData?.getData(\"text/plain\")\n                    if (html) {\n                        const sanitized = sanitizeHTML(html)\n                        const currentCenter = coordinateHelper.currentBoardCoordinates.get()\n                        let toCreate\n                        if (sanitized.length > 50) {\n                            toCreate = [newText(board.get().crdt, sanitized, currentCenter.x, currentCenter.y)]\n                        } else {\n                            toCreate = [newNote(sanitized, DEFAULT_NOTE_COLOR, currentCenter.x, currentCenter.y)]\n                        }\n                        dispatch({ action: \"item.add\", boardId: currentBoard.id, items: toCreate, connections: [] })\n                    } else if (e.clipboardData) {\n                        console.log(\n                            \"Unsupported data from clipboard.\",\n                            Object.fromEntries(e.clipboardData.types.map((t) => [t, e.clipboardData?.getData(t)])),\n                        )\n                    }\n                } else {\n                    const clipboard = JSON.parse(rboardData) as ItemsAndConnections\n                    if (!(\"items\" in clipboard)) {\n                        console.warn(\"Unexpected clipboard content\", clipboard)\n                        return\n                    }\n                    const requiredMargin = BOARD_ITEM_BORDER_MARGIN\n                    const itemRecord = Object.fromEntries(clipboard.items.map((i) => [i.id, i]))\n                    const theItems = [...clipboard.items, ...clipboard.connections.map(connectionRect(itemRecord))]\n                    if (theItems.length === 0) {\n                        console.log(\"Empty clipboard\")\n                        return\n                    }\n                    const xDiffMin = -_.min(theItems.map((i) => i.x))! + requiredMargin\n                    const yDiffMin = -_.min(theItems.map((i) => i.y))! + requiredMargin\n                    const xDiffMax = board.get().width - _.max(theItems.map((i) => i.x + i.width))! - requiredMargin\n                    const yDiffMax = board.get().height - _.max(theItems.map((i) => i.y + i.height))! - requiredMargin\n                    const xCenterOld = _.sum(theItems.map((i) => i.x + i.width / 2)) / theItems.length\n                    const yCenterOld = _.sum(theItems.map((i) => i.y + i.height / 2)) / theItems.length\n                    const currentCenter = coordinateHelper.currentBoardCoordinates.get()\n\n                    const xDiff = Math.min(Math.max(currentCenter.x - xCenterOld, xDiffMin), xDiffMax)\n                    const yDiff = Math.min(Math.max(currentCenter.y - yCenterOld, yDiffMin), yDiffMax)\n                    const { toCreate, toSelect, connections } = makeCopies(clipboard, xDiff, yDiff)\n\n                    dispatch({ action: \"item.add\", boardId: currentBoard.id, items: toCreate, connections })\n                    focus.set({\n                        status: \"selected\",\n                        itemIds: new Set(toSelect.map((it) => it.id)),\n                        connectionIds: emptySet(),\n                    })\n                    e.preventDefault()\n                }\n                break\n            }\n        }\n    }\n\n    CLIPBOARD_EVENTS.forEach((eventType) => {\n        document.addEventListener(eventType, clipboardEventHandler)\n    })\n\n    return () => {\n        CLIPBOARD_EVENTS.forEach((eventType) => {\n            document.removeEventListener(eventType, clipboardEventHandler)\n        })\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/item-delete.ts",
    "content": "import * as L from \"lonna\"\nimport { Id } from \"../../../common/src/domain\"\nimport { Dispatch } from \"../store/board-store\"\nimport { BoardFocus, getSelectedConnectionIds, getSelectedItemIds } from \"./board-focus\"\nimport { installKeyboardShortcut, plainKey } from \"./keyboard-shortcuts\"\n\nexport function itemDeleteHandler(boardId: Id, dispatch: Dispatch, focus: L.Property<BoardFocus>) {\n    installKeyboardShortcut(plainKey(\"Delete\", \"Backspace\"), () => {\n        const f = focus.get()\n        dispatchDeletion(boardId, f, dispatch)\n    })\n}\n\nexport function dispatchDeletion(boardId: Id, f: BoardFocus, dispatch: Dispatch) {\n    const connectionIds = [...getSelectedConnectionIds(f)]\n    const itemIds = [...getSelectedItemIds(f)]\n    if (itemIds.length || connectionIds.length) {\n        dispatch({ action: \"item.delete\", boardId, itemIds, connectionIds })\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/item-drag.ts",
    "content": "import * as L from \"lonna\"\nimport { Board, Connection, getConnection, getItem, Item, Point } from \"../../../common/src/domain\"\nimport { emptySet } from \"../../../common/src/sets\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { BoardFocus, getSelectedItemIds } from \"./board-focus\"\nimport { isSingleTouch } from \"./touchScreen\"\n\nexport const DND_GHOST_HIDING_IMAGE = new Image()\n// https://png-pixel.com/\nDND_GHOST_HIDING_IMAGE.src =\n    \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==\"\n\nexport function onBoardItemDrag(\n    elem: HTMLElement,\n    id: string,\n    board: L.Property<Board>,\n    focus: L.Atom<BoardFocus>,\n    coordinateHelper: BoardCoordinateHelper,\n    onlyWhenSelected: boolean,\n    doWhileDragging: (\n        // TODO: this abstraction is leaking\n        b: Board,\n        dragStartPosition: Point,\n        items: { current: Item; dragStartPosition: Item }[],\n        connections: { current: Connection; dragStartPosition: Connection }[],\n        xDiff: number,\n        yDiff: number,\n    ) => void,\n    doOnDrop?: (b: Board, current: Item[]) => void,\n) {\n    type Drag = { pageX: number; pageY: number; preventDefault: () => void; stopPropagation: () => void }\n    type DragEnd = { stopPropagation: () => void }\n\n    const touch2Drag = (e: TouchEvent): Drag => {\n        return {\n            pageX: e.touches[0].pageX,\n            pageY: e.touches[0].pageY,\n            stopPropagation: () => e.stopPropagation(),\n            preventDefault: () => e.preventDefault(),\n        }\n    }\n    const touch2DragEnd = (e: TouchEvent): DragEnd => {\n        return {\n            stopPropagation: () => {},\n        }\n    }\n\n    let dragStart: Drag | null = null\n    let dragStartPositions: Board\n    let currentPos: { x: number; y: number } | null = null\n\n    const dragEnabled = onlyWhenSelected ? L.view(focus, (f) => getSelectedItemIds(f).has(id)) : L.constant(true)\n\n    const onDragStart = (e: DragEvent) => {\n        e.dataTransfer?.setDragImage(DND_GHOST_HIDING_IMAGE, 0, 0)\n        startDrag(e)\n    }\n    const startDrag = (e: Drag) => {\n        e.stopPropagation()\n        const f = focus.get()\n        if (f.status === \"dragging\") {\n            if (!f.itemIds.has(id)) {\n                focus.set({ status: \"dragging\", itemIds: new Set([id]), connectionIds: emptySet() })\n            }\n        } else if (f.status === \"selected\" && f.itemIds.has(id)) {\n            focus.set({ status: \"dragging\", itemIds: f.itemIds, connectionIds: f.connectionIds })\n        } else {\n            focus.set({ status: \"dragging\", itemIds: new Set([id]), connectionIds: emptySet() })\n        }\n\n        dragStart = e\n        dragStartPositions = board.get()\n    }\n\n    const onTouchMove = (e: TouchEvent) => {\n        e.preventDefault()\n        if (isSingleTouch(e)) {\n            const d = touch2Drag(e)\n            const f = focus.get()\n            if (f.status !== \"dragging\") {\n                startDrag(touch2Drag(e))\n            }\n\n            coordinateHelper.currentPageCoordinates.set({ x: d.pageX, y: d.pageY })\n            drag(d)\n        }\n    }\n    const onDrag = (e: DragEvent) => {\n        drag(e)\n    }\n\n    const drag = (e: Drag) => {\n        e.stopPropagation()\n        const f = focus.get()\n        if (f.status !== \"dragging\") {\n            e.preventDefault()\n            return\n        }\n        const newPos = coordinateHelper.boardCoordDiffFromThisPageCoordinate({\n            x: dragStart!.pageX,\n            y: dragStart!.pageY,\n        })\n        if (currentPos && currentPos.x == newPos.x && currentPos.y === newPos.y) {\n            return\n        }\n        currentPos = newPos\n        const { x: xDiff, y: yDiff } = newPos\n\n        const b = board.get()\n        const items = [...f.itemIds].map((id) => {\n            const current = b.items[id]\n            const dragStartPosition = dragStartPositions.items[id]\n            if (!current || !dragStartPosition) throw Error(\"Item not found: \" + id)\n            return {\n                current,\n                dragStartPosition,\n            }\n        })\n        const connections = [...f.connectionIds].map((id) => {\n            const current = getConnection(b)(id)\n            const dragStartPosition = getConnection(dragStartPositions)(id)\n            if (!current || !dragStartPosition) throw Error(\"Connection not found: \" + id)\n            return { current, dragStartPosition }\n        })\n        const dragStartBoardPos = coordinateHelper.pageToBoardCoordinates({\n            x: dragStart!.pageX,\n            y: dragStart!.pageY,\n        })\n        doWhileDragging(b, dragStartBoardPos, items, connections, xDiff, yDiff)\n    }\n\n    const onTouchEnd = (e: TouchEvent) => {\n        e.preventDefault()\n        if (isSingleTouch(e)) {\n            dragEnd(touch2DragEnd(e))\n        }\n    }\n    const onDragEnd = (e: DragEvent) => {\n        dragEnd(e)\n    }\n    const dragEnd = (e: DragEnd) => {\n        e.stopPropagation()\n        focus.modify((f) => {\n            if (f.status !== \"dragging\") {\n                return f\n            }\n            if (doOnDrop) {\n                const b = board.get()\n                const items = [...f.itemIds].map(getItem(b))\n                doOnDrop(b, items)\n            }\n            currentPos = null\n            return { status: \"selected\", itemIds: f.itemIds, connectionIds: f.connectionIds }\n        })\n    }\n\n    dragEnabled.forEach((enabled) => {\n        if (enabled) {\n            elem.addEventListener(\"dragstart\", onDragStart)\n            elem.addEventListener(\"drag\", onDrag)\n            elem.addEventListener(\"dragend\", onDragEnd)\n            elem.addEventListener(\"touchmove\", onTouchMove)\n            elem.addEventListener(\"touchend\", onTouchEnd)\n        } else {\n            elem.removeEventListener(\"dragstart\", onDragStart)\n            elem.removeEventListener(\"drag\", onDrag)\n            elem.removeEventListener(\"dragend\", onDragEnd)\n            elem.removeEventListener(\"touchmove\", onTouchMove)\n            elem.removeEventListener(\"touchend\", onTouchEnd)\n        }\n    })\n}\n"
  },
  {
    "path": "frontend/src/board/item-dragmove.ts",
    "content": "import * as L from \"lonna\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { Board, BOARD_ITEM_BORDER_MARGIN, Connection, isItemEndPoint, Item, Point } from \"../../../common/src/domain\"\nimport { BoardFocus } from \"./board-focus\"\nimport { onBoardItemDrag } from \"./item-drag\"\nimport { maybeChangeContainerForItem } from \"./item-setcontainer\"\nimport { Dispatch } from \"../store/board-store\"\nimport { newConnectionCreator, isConnectionAttachmentPoint } from \"./item-connect\"\nimport { Tool, ToolController } from \"./tool-selection\"\nimport { connectionRect } from \"../../../common/src/connection-utils\"\n\nexport function itemDragToMove(\n    id: string,\n    board: L.Property<Board>,\n    focus: L.Atom<BoardFocus>,\n    toolController: ToolController,\n    coordinateHelper: BoardCoordinateHelper,\n    latestConnection: L.Property<Connection | null>,\n    dispatch: Dispatch,\n    onlyWhenSelected: boolean,\n) {\n    const connector = newConnectionCreator(board, focus, latestConnection, dispatch)\n    return (elem: HTMLElement) =>\n        onBoardItemDrag(\n            elem,\n            id,\n            board,\n            focus,\n            coordinateHelper,\n            onlyWhenSelected,\n            (b, startPos, items, connections, xDiff, yDiff) => {\n                // Cant drag when connect tool is active\n                const t = toolController.tool.get()\n\n                // While dragging\n                const f = focus.get()\n                if (f.status !== \"dragging\") throw Error(\"Assertion fail\")\n\n                // TODO: disable multiple selection in connect mode\n\n                if (t === \"connect\" || t === \"line\") {\n                    const { current, dragStartPosition } = items[0]\n                    const from = isConnectionAttachmentPoint(startPos, current) && t === \"connect\" ? current : startPos\n                    connector.whileDragging(from, coordinateHelper.currentBoardCoordinates.get(), t)\n                } else {\n                    const margin = BOARD_ITEM_BORDER_MARGIN\n                    const movedItems = items.map(({ dragStartPosition, current }) => {\n                        const x = Math.min(\n                            Math.max(dragStartPosition.x + xDiff, margin),\n                            b.width - current.width - margin,\n                        )\n                        const y = Math.min(\n                            Math.max(dragStartPosition.y + yDiff, margin),\n                            b.height - current.height - margin,\n                        )\n                        const container = maybeChangeContainerForItem(current, b.items)\n                        return { id: current.id, x, y, containerId: container ? container.id : undefined }\n                    })\n\n                    const movedConnections = connections.flatMap(({ dragStartPosition, current }) => {\n                        if (\n                            isItemEndPoint(current.from) ||\n                            isItemEndPoint(current.to) ||\n                            isItemEndPoint(dragStartPosition.from) ||\n                            isItemEndPoint(dragStartPosition.to)\n                        )\n                            return []\n                        const currentRect = connectionRect(b)(current)\n                        const x = Math.min(\n                            Math.max(dragStartPosition.from.x + xDiff, margin),\n                            b.width - currentRect.width - margin,\n                        )\n                        const y = Math.min(\n                            Math.max(dragStartPosition.from.y + yDiff, margin),\n                            b.height - currentRect.height - margin,\n                        )\n                        return { id: current.id, x, y }\n                    })\n\n                    dispatch({ action: \"item.move\", boardId: b.id, items: movedItems, connections: movedConnections })\n                }\n            },\n            () => {\n                connector.endDrag()\n                toolController.useDefaultTool()\n            },\n        )\n}\n"
  },
  {
    "path": "frontend/src/board/item-duplicate.ts",
    "content": "import * as L from \"lonna\"\nimport { Board } from \"../../../common/src/domain\"\nimport { emptySet } from \"../../../common/src/sets\"\nimport { Dispatch } from \"../store/board-store\"\nimport { BoardFocus } from \"./board-focus\"\nimport { augmentWithCRDT, findSelectedItemsAndConnections, makeCopies } from \"./item-cut-copy-paste\"\nimport { controlKey, installKeyboardShortcut } from \"./keyboard-shortcuts\"\nimport { CRDTStore } from \"../store/crdt-store\"\n\nexport function itemDuplicateHandler(\n    board: L.Property<Board>,\n    crdtStore: CRDTStore,\n    dispatch: Dispatch,\n    focus: L.Atom<BoardFocus>,\n) {\n    installKeyboardShortcut(controlKey(\"d\"), () => {\n        dispatchDuplication(focus, board.get(), dispatch, crdtStore)\n    })\n}\n\nexport function dispatchDuplication(\n    focus: L.Atom<BoardFocus>,\n    currentBoard: Board,\n    dispatch: Dispatch,\n    crdtStore: CRDTStore,\n) {\n    const itemsAndConnections = augmentWithCRDT(\n        currentBoard.id,\n        findSelectedItemsAndConnections(focus.get(), currentBoard),\n        crdtStore,\n    )\n    const { toCreate, toSelect, connections } = makeCopies(itemsAndConnections, 1, 1)\n    dispatch({ action: \"item.add\", boardId: currentBoard.id, items: toCreate, connections })\n    focus.set({ status: \"selected\", itemIds: new Set(toSelect.map((it) => it.id)), connectionIds: emptySet() })\n}\n"
  },
  {
    "path": "frontend/src/board/item-hide-contents.ts",
    "content": "import { Board, isContainer, Item, Note } from \"../../../common/src/domain\"\nimport { Dispatch } from \"../store/board-store\"\nimport { BoardFocus, getSelectedItems } from \"./board-focus\"\nimport { getIfSame } from \"./contextmenu/textAlignments\"\nimport * as L from \"lonna\"\nimport { installKeyboardShortcut, plainKey } from \"./keyboard-shortcuts\"\n\nexport function itemHideContentsHandler(board: L.Property<Board>, focus: L.Property<BoardFocus>, dispatch: Dispatch) {\n    installKeyboardShortcut(plainKey(\"h\"), () =>\n        toggleContentsHidden(getSelectedItems(board.get())(focus.get()), board.get(), dispatch),\n    )\n}\n\nexport function hasContentHidden(items: Item[]) {\n    return getIfSame(items, (item) => (isContainer(item) && item.contentsHidden) ?? false, false)\n}\n\nexport function toggleContentsHidden(items: Item[], board: Board, dispatch: Dispatch) {\n    const hidden = hasContentHidden(items)\n\n    dispatch({\n        action: \"item.update\",\n        boardId: board.id,\n        items: findContainers(items, board).map((c) => ({\n            id: c.id,\n            contentsHidden: !hidden,\n        })),\n    })\n}\n\nfunction findContainers(items: Item[], board: Board): Item[] {\n    const containers = items.filter(isContainer)\n    const leftOverItems = items.filter((i) => !isContainer(i) && !containers.some((c) => c.id === i.containerId))\n    const containersForLeftOverItems = leftOverItems\n        .map((i) => board.items[i.containerId ?? \"\"])\n        .filter((i) => i && !containers.some((c) => c.id === i.id))\n    return [...containers, ...containersForLeftOverItems]\n}\n"
  },
  {
    "path": "frontend/src/board/item-move-with-arrow-keys.ts",
    "content": "import * as L from \"lonna\"\nimport { connectionRect, resolveEndpoint } from \"../../../common/src/connection-utils\"\nimport { Board, BOARD_ITEM_BORDER_MARGIN } from \"../../../common/src/domain\"\nimport { Rect } from \"../../../common/src/geometry\"\nimport { Dispatch } from \"../store/board-store\"\nimport { BoardFocus } from \"./board-focus\"\nimport { findSelectedItemsAndConnections } from \"./item-cut-copy-paste\"\nimport { installKeyboardShortcut } from \"./keyboard-shortcuts\"\n\nfunction updatePosition<T extends Rect>(board: Board, item: T, dx: number, dy: number): T {\n    const margin = BOARD_ITEM_BORDER_MARGIN\n    return {\n        ...item,\n        x: Math.min(Math.max(item.x + dx, margin), board.width - item.width - margin),\n        y: Math.min(Math.max(item.y + dy, margin), board.height - item.height - margin),\n    }\n}\n\nfunction moveItem<T extends Rect>(board: Board, item: T, key: string, shiftKey: boolean, altKey: boolean): T {\n    const stepSize = shiftKey ? 10 : altKey ? 0.1 : 1\n    switch (key) {\n        case \"ArrowLeft\":\n            return updatePosition(board, item, -stepSize, 0)\n        case \"ArrowRight\":\n            return updatePosition(board, item, stepSize, 0)\n        case \"ArrowUp\":\n            return updatePosition(board, item, 0, -stepSize)\n        case \"ArrowDown\":\n            return updatePosition(board, item, 0, stepSize)\n    }\n    return item\n}\n\nexport function itemMoveWithArrowKeysHandler(board: L.Property<Board>, dispatch: Dispatch, focus: L.Atom<BoardFocus>) {\n    installKeyboardShortcut(\n        (e) => [\"ArrowLeft\", \"ArrowRight\", \"ArrowUp\", \"ArrowDown\"].includes(e.key),\n        (e) => {\n            const currentBoard = board.get()\n            const itemsAndConnections = findSelectedItemsAndConnections(focus.get(), currentBoard)\n            if (itemsAndConnections.items.length > 0 || itemsAndConnections.connections.length > 0) {\n                const movedItems = itemsAndConnections.items.map((item) =>\n                    moveItem(currentBoard, item, e.key, e.shiftKey, e.altKey),\n                )\n                const movedConnections = itemsAndConnections.connections.map((connection) => {\n                    const rect = connectionRect(currentBoard)(connection)\n                    const movedRect = moveItem(currentBoard, rect, e.key, e.shiftKey, e.altKey)\n                    const xDiff = movedRect.x - rect.x\n                    const yDiff = movedRect.y - rect.y\n                    const startPoint = resolveEndpoint(connection.from, currentBoard)\n                    return { id: connection.id, x: startPoint.x + xDiff, y: startPoint.y + yDiff }\n                })\n                dispatch({\n                    action: \"item.move\",\n                    boardId: currentBoard.id,\n                    items: movedItems,\n                    connections: movedConnections,\n                })\n            }\n        },\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/item-organizer.test.ts",
    "content": "import { describe, expect, it } from \"vitest\"\n\nimport { newNote } from \"../../../common/src/domain\"\nimport { overlaps } from \"../../../common/src/geometry\"\nimport { organizeItems } from \"./item-organizer\"\n\ndescribe(\"organizeItems\", () => {\n    const rect = { x: 10, y: 10, width: 100, height: 100 }\n    const itemsToAvoid = [{ ...newNote(\"a1\"), x: 10, y: 10, width: 40, height: 40 }]\n    const b1 = { ...newNote(\"b1\"), width: 20, height: 20 }\n    const b2 = { ...newNote(\"b2\"), width: 20, height: 20 }\n\n    it(\"Overlap\", () => {\n        expect(overlaps({ x: 10, y: 10, width: 5, height: 5 }, { x: 9, y: 9, width: 42, height: 42 })).toEqual(true)\n        expect(overlaps({ x: 51, y: 10, width: 5, height: 5 }, { x: 9, y: 9, width: 42, height: 42 })).toEqual(false)\n    })\n    it(\"Single row\", () => {\n        const itemsToPlace = [b1, b2]\n        expect(organizeItems(itemsToPlace, itemsToAvoid, rect)).toEqual([\n            { ...b1, x: 51, y: 10 },\n            { ...b2, x: 72, y: 10 },\n        ])\n    })\n\n    it(\"Multiple rows\", () => {\n        const b3 = { ...newNote(\"b3\"), width: 80, height: 20 }\n        const itemsToPlace = [b1, b2, b3]\n        const result1 = [\n            { ...b1, x: 51, y: 10 },\n            { ...b2, x: 72, y: 10 },\n            { ...b3, x: 10, y: 51 },\n        ]\n        expect(organizeItems(itemsToPlace, itemsToAvoid, rect)).toEqual(result1)\n\n        // Now test that it's somewhat stable when it comes to existing placements\n        const result1Shuffled = [\n            { ...b3, x: 10, y: 51 },\n            { ...b2, x: 72, y: 10 },\n            { ...b1, x: 51, y: 10 },\n        ]\n        expect(organizeItems(result1Shuffled, itemsToAvoid, rect)).toEqual([])\n    })\n\n    it(\"Clean new rows when different sizes on a row\", () => {\n        const itemsToAvoid = [\n            { ...newNote(\"a1\"), x: 10, y: 10, width: 10, height: 50 },\n            { ...newNote(\"a2\"), x: 20, y: 10, width: 90, height: 10 },\n        ]\n        const itemsToPlace = [b1, b2]\n        expect(organizeItems(itemsToPlace, itemsToAvoid, rect)).toEqual([\n            { ...b1, x: 10, y: 61 },\n            { ...b2, x: 31, y: 61 },\n        ])\n    })\n})\n"
  },
  {
    "path": "frontend/src/board/item-organizer.ts",
    "content": "import { Board, Container, Item, ItemUpdate } from \"../../../common/src/domain\"\nimport * as G from \"../../../common/src/geometry\"\nimport _ from \"lodash\"\n\nconst rowResolution = 1\nexport const ITEM_MARGIN = 1\nexport const CONTAINER_MARGIN = 1\n\n// This function is run recursively 'maxAttempts' times to find a good fit\nexport function contentRect(cont: Container): G.Rect {\n    const { width, height } = cont\n    const borderTop = (cont.fontSize ?? CONTAINER_MARGIN) * 2\n    const otherBorders = CONTAINER_MARGIN\n    const borderBottom = otherBorders\n    const borderLeft = otherBorders\n    const borderRight = otherBorders\n    return {\n        x: cont.x + borderLeft,\n        y: cont.y + borderTop,\n        width: cont.width - borderLeft - borderRight,\n        height: cont.height - borderTop - borderBottom,\n    }\n}\n\nexport function packableItems(cont: Container, board: Board): Item[] {\n    const is = board.items\n    const values = Object.values(is)\n    // Packing containers-in-containers not supported yet, and resizing text seems to cause overflow issues\n    const items = values.filter((v) => v.containerId === cont.id && v.type !== \"text\" && v.type !== \"container\")\n    return items\n}\n\n// TODO: return only changed attributes in ItemUpdate - currently returns full Items\nexport function organizeItems(itemsToPack: Item[], itemsToAvoid: Item[], rect: G.Rect): ItemUpdate[] {\n    if (itemsToPack.length === 0) return itemsToPack\n    const results: Item[] = []\n    let rowY = rect.y\n    let colX = rect.x\n    const rowNumber = (i: Item) => Math.floor((i.y * rect.width) / rowResolution)\n    const colNumber = (i: Item) => i.x\n\n    itemsToPack = _.orderBy(itemsToPack, [rowNumber, colNumber])\n    for (let itemToPlace of itemsToPack) {\n        let item: Item\n        ;({ item, colX, rowY } = placeItem(itemToPlace, itemsToAvoid, rect, rowY, colX))\n        itemsToAvoid = [...itemsToAvoid, item]\n        if (item.x !== itemToPlace.x || item.y !== itemToPlace.y) {\n            results.push(item)\n        }\n    }\n    return results\n}\n\nexport function placeItem(\n    item: Item,\n    itemsToAvoid: Item[],\n    rect: G.Rect,\n    rowY: number,\n    colX: number,\n): { item: Item; rowY: number; colX: number } {\n    for (let i = 0; i < 1000000; i++) {\n        let place = { x: colX, y: rowY, width: item.width, height: item.height }\n        //console.log(place)\n        const toAvoidWithMargin = itemsToAvoid.map((i) => marginRect(ITEM_MARGIN, i))\n        let overlapping = toAvoidWithMargin.filter((r) => G.overlaps(place, r))\n        if (overlapping.length === 0) {\n            return { item: { ...item, ...place }, rowY, colX }\n        } else {\n            //console.log(\"Overlaps\", overlapping)\n            let nextX = _.max(overlapping.map((r) => r.x + r.width))!\n            if (nextX + item.width <= rect.x + rect.width) {\n                // try same row\n                colX = nextX\n            } else {\n                colX = rect.x\n                const rowArea = { x: rect.x, y: rowY, width: rect.width, height: place.height }\n                const rowItemsWithMargin = toAvoidWithMargin.filter((i) => G.overlaps(i, rowArea))\n                const maxY = _.max(rowItemsWithMargin.map((i) => i.y + i.height))!\n                rowY = maxY\n            }\n        }\n    }\n    throw Error(\"Failed to pack\")\n}\n\nfunction marginRect(margin: number, r: G.Rect): G.Rect {\n    return { x: r.x - margin, y: r.y - margin, width: r.width + 2 * margin, height: r.height + 2 * margin }\n}\n"
  },
  {
    "path": "frontend/src/board/item-packer.ts",
    "content": "// @ts-ignore\nimport { BP2D } from \"binpackingjs\"\nconst { Bin, Box, Packer, heuristics } = BP2D\nimport { Board, Container, Item, ItemUpdate } from \"../../../common/src/domain\"\nimport { Rect } from \"../../../common/src/geometry\"\nimport { ITEM_MARGIN } from \"./item-organizer\"\n\ntype PackItemsResult =\n    | {\n          ok: true\n          packedItems: ItemUpdate[]\n      }\n    | {\n          ok: false\n          error: string\n      }\n\nconst PACK_BINARY_SEARCH_DEFAULT: {\n    max: number\n    min: number\n    multiplier: number\n    attempt: number\n    maxAttempts: number\n    prev: (Item[] | null)[]\n} = {\n    max: 1,\n    min: 0,\n    multiplier: 0.5,\n    attempt: 1,\n    maxAttempts: 20,\n    prev: [],\n}\n\n// TODO: return only changed attributes in ItemUpdate - currently returns full Items\nexport function packItems(targetRect: Rect, items: Item[], binarySearch = PACK_BINARY_SEARCH_DEFAULT): PackItemsResult {\n    const availableWidth = targetRect.width\n    const availableHeight = targetRect.height\n\n    const b = new Bin(availableWidth, availableHeight, new heuristics.BottomLeft())\n    const p = new Packer([b])\n    const avgHeight = items.reduce((acc, i) => i.height + acc, 0) / items.length\n\n    const availableArea = availableWidth * availableHeight * binarySearch.multiplier\n\n    function totalArea(its: { width: number; height: number }[]) {\n        return its.reduce((acc, it) => it.width * it.height + acc, 0)\n    }\n\n    function scaleItems(its: { width: number; height: number }[], scale: number) {\n        const multipleOfAverageHeight = (it: { width: number; height: number }) => Math.round(it.height / avgHeight)\n        const widthMultiplier = (it: { width: number; height: number }) =>\n            (multipleOfAverageHeight(it) * avgHeight * scale) / it.height\n\n        const boxes = its.map((it) => {\n            const width = widthMultiplier(it) * it.width + ITEM_MARGIN\n            const height = multipleOfAverageHeight(it) * avgHeight * scale + ITEM_MARGIN\n            const box = new Box(width, height, true)\n            box.data = { ...it, width, height }\n            return box\n        })\n        return boxes\n    }\n\n    let scale = 1\n    let maxScale = 1\n    let minScale = 0\n    let itemsToPack = scaleItems(items, scale)\n\n    if (totalArea(itemsToPack) > availableArea) {\n        // binary search for a while to find a good fit\n        for (let i = 0; i < 100; i++) {\n            scale = minScale + (maxScale - minScale) / 2\n            itemsToPack = scaleItems(items, scale)\n            if (totalArea(itemsToPack) > availableArea) {\n                maxScale = scale\n            } else {\n                minScale = scale\n            }\n        }\n    }\n\n    const packedBoxes = p.pack(itemsToPack)\n\n    let newItems: Item[] | null = null\n    // The maxrects algorithm was designed for packing sprites into 'n' bind,\n    // we only want there to be one bin that contains all of our items\n    if (items.length === packedBoxes.length) {\n        newItems = items.map((it) => {\n            const rect = packedBoxes.find((p: any) => p.data.id === it.id)!\n            return {\n                ...it,\n                width: rect.width - ITEM_MARGIN,\n                height: rect.height - ITEM_MARGIN,\n                x: targetRect.x + rect.x,\n                y: targetRect.y + rect.y,\n            }\n        })\n    }\n\n    const failed = newItems === null\n\n    if (binarySearch.attempt <= binarySearch.maxAttempts) {\n        const max = failed ? binarySearch.multiplier : binarySearch.max\n        const min = failed ? binarySearch.min : binarySearch.multiplier\n        const multiplier = min + (max - min) / 2\n        const newBinaryParams = {\n            max,\n            min,\n            multiplier,\n            attempt: binarySearch.attempt + 1,\n            maxAttempts: binarySearch.maxAttempts,\n            prev: [newItems, ...binarySearch.prev],\n        }\n        return packItems(targetRect, items, newBinaryParams)\n    }\n\n    const finalItems = [newItems, ...binarySearch.prev].find((candidate) => candidate !== null)\n\n    return !finalItems\n        ? {\n              ok: false,\n              error: \"no fit\",\n          }\n        : {\n              ok: true,\n              packedItems: finalItems,\n          }\n}\n"
  },
  {
    "path": "frontend/src/board/item-select-all.ts",
    "content": "import * as L from \"lonna\"\nimport { Board } from \"../../../common/src/domain\"\nimport { BoardFocus } from \"./board-focus\"\nimport { controlKey, installKeyboardShortcut } from \"./keyboard-shortcuts\"\n\nexport function itemSelectAllHandler(board: L.Property<Board>, focus: L.Atom<BoardFocus>) {\n    installKeyboardShortcut(controlKey(\"a\"), () =>\n        focus.set({\n            status: \"selected\",\n            itemIds: new Set(Object.keys(board.get().items)),\n            connectionIds: new Set(board.get().connections.map((c) => c.id)),\n        }),\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/item-selection.ts",
    "content": "import * as L from \"lonna\"\nimport { Board, Connection, ItemType } from \"../../../common/src/domain\"\nimport { emptySet, toggleInSet } from \"../../../common/src/sets\"\nimport { Dispatch } from \"../store/board-store\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { BoardFocus, getSelectedConnectionIds, getSelectedItemIds } from \"./board-focus\"\nimport { isConnectionAttachmentPoint, startConnecting } from \"./item-connect\"\nimport { ToolController } from \"./tool-selection\"\nimport { isSingleTouch } from \"./touchScreen\"\n\nexport function itemSelectionHandler(\n    id: string,\n    type: ItemType,\n    focus: L.Atom<BoardFocus>,\n    toolController: ToolController,\n    board: L.Property<Board>,\n    coordinateHelper: BoardCoordinateHelper,\n    latestConnection: L.Property<Connection | null>,\n    dispatch: Dispatch,\n) {\n    const itemFocus = L.view(focus, (f) => {\n        if (f.status === \"none\" || f.status === \"adding\" || f.status === \"connection-adding\") return \"none\"\n        if (f.status === \"selected\") return f.itemIds.has(id) ? \"selected\" : \"none\"\n        if (f.status === \"dragging\") return f.itemIds.has(id) ? \"dragging\" : \"none\"\n        if (f.status === \"editing\") return f.itemId === id ? \"editing\" : \"none\"\n        return \"none\"\n    })\n\n    const selected = L.view(itemFocus, (s) => s !== \"none\")\n\n    function onTouchStart(e: JSX.TouchEvent) {\n        if (isSingleTouch(e)) {\n            onClick(e)\n        }\n    }\n    function onClick(e: JSX.MouseEvent | JSX.TouchEvent) {\n        const f = focus.get()\n        const selectedIds = getSelectedItemIds(f)\n        const tool = toolController.tool.get()\n        if (tool === \"connect\") {\n            const item = board.get().items[id]\n            const point = coordinateHelper.currentBoardCoordinates.get()\n            const from = isConnectionAttachmentPoint(point, item)\n                ? item\n                : coordinateHelper.currentBoardCoordinates.get()\n            startConnecting(board, coordinateHelper, latestConnection, dispatch, toolController, focus, from)\n            e.stopPropagation()\n        } else if (e.shiftKey && (f.status === \"selected\" || f.status === \"editing\")) {\n            focus.set({\n                status: \"selected\",\n                itemIds: toggleInSet(id, selectedIds),\n                connectionIds: getSelectedConnectionIds(f),\n            })\n        } else if (f.status === \"none\") {\n            focus.set({ status: \"selected\", itemIds: new Set([id]), connectionIds: emptySet() })\n        } else if ((f.status === \"selected\" || f.status === \"editing\") && !selectedIds.has(id)) {\n            focus.set({ status: \"selected\", itemIds: new Set([id]), connectionIds: emptySet() })\n        } else if (f.status === \"selected\" && (type === \"note\" || type === \"text\")) {\n            focus.set({ status: \"editing\", itemId: id })\n        }\n    }\n\n    return {\n        itemFocus,\n        selected,\n        onClick,\n        onTouchStart,\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/item-setcontainer.ts",
    "content": "import { isFullyContainedConnection } from \"../../../common/src/connection-utils\"\nimport { Board, Connection, Id, Item } from \"../../../common/src/domain\"\nimport { containedBy } from \"../../../common/src/geometry\"\n\nexport function maybeChangeContainerForItem(item: Item, items: Record<Id, Item>): Item | undefined {\n    const candidates = Object.values(items)\n        .filter((i) => i.type === \"container\" && i.id !== item.id && containedBy(item, i)) // contain the item coordinate-wise\n        .sort((a, b) => (containedBy(b, a) ? 1 : -1)) // most innermost first (containers last)\n\n    return candidates[0]\n}\n\nexport function maybeChangeContainerForConnection(connection: Connection, items: Record<Id, Item>): Item | undefined {\n    const candidates = Object.values(items)\n        .filter((i) => isFullyContainedConnection(connection, i, items)) // contains connection start and endpoints\n        .sort((a, b) => (containedBy(b, a) ? 1 : -1)) // most innermost first (containers last)\n\n    return candidates[0]\n}\n\nexport function withCurrentContainer(item: Item, b: Board): Item {\n    const newContainer = maybeChangeContainerForItem(item, b.items)\n    const containerId = newContainer ? newContainer.id : undefined\n\n    return { ...item, containerId }\n}\n"
  },
  {
    "path": "frontend/src/board/item-undo-redo.ts",
    "content": "import { Dispatch } from \"../store/board-store\"\nimport { controlKey, installKeyboardShortcut } from \"./keyboard-shortcuts\"\n\nexport function itemUndoHandler(dispatch: Dispatch) {\n    installKeyboardShortcut(controlKey(\"z\"), (e) => {\n        dispatch({ action: e.shiftKey ? \"ui.redo\" : \"ui.undo\" })\n    })\n}\n"
  },
  {
    "path": "frontend/src/board/keyboard-shortcuts.ts",
    "content": "import { componentScope } from \"harmaja\"\nimport * as L from \"lonna\"\n\nexport function installKeyboardShortcut(\n    selector: (e: JSX.KeyboardEvent) => boolean,\n    action: (e: JSX.KeyboardEvent) => void,\n) {\n    ;[\"keydown\", \"keyup\", \"keypress\"].forEach((eventName) => {\n        // Prevent default for all of these to prevent Backspace=Back behavior on Firefox\n        L.fromEvent<JSX.KeyboardEvent>(document, eventName)\n            .pipe(L.applyScope(componentScope()))\n            .forEach((e) => {\n                if (selector(e)) {\n                    e.preventDefault()\n                    if (eventName === \"keydown\") {\n                        action(e)\n                    }\n                }\n            })\n    })\n}\n\nexport const plainKey = (...k: string[]) => (event: JSX.KeyboardEvent) => {\n    return !(event.shiftKey || event.altKey || event.metaKey || event.ctrlKey) && k.includes(event.key)\n}\n\nexport const controlKey = (...k: string[]) => (event: JSX.KeyboardEvent) => {\n    return (event.metaKey || event.ctrlKey) && k.includes(event.key)\n}\n"
  },
  {
    "path": "frontend/src/board/local-storage-atom.ts",
    "content": "import * as L from \"lonna\"\n\nexport function localStorageAtom<T>(key: string, defaultValue: T) {\n    const initialValue = localStorage[key] !== undefined ? JSON.parse(localStorage[key]) : defaultValue\n    const atom = L.atom<T>(initialValue)\n    atom.onChange((v) => (localStorage[key] = JSON.stringify(v)))\n    return atom\n}\n"
  },
  {
    "path": "frontend/src/board/quillClickableLink.ts",
    "content": "import Quill from \"quill\"\nvar Link = Quill.import(\"formats/link\")\n\nexport default class ClickableLink extends Link {\n    static create(href: any) {\n        let node = super.create(href) as HTMLAnchorElement\n        node.title = href\n        node.addEventListener(\"click\", (e) => {\n            e.stopPropagation()\n            e.preventDefault()\n            window.open(href, \"_blank\")\n        })\n        return node\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/quillPasteLinkOverText.ts",
    "content": "import Quill from \"quill\"\nimport { isURL } from \"../components/sanitizeHTML\"\n\nexport default class PasteLinkOverText {\n    constructor(quill: Quill) {\n        quill.clipboard.addMatcher(Node.TEXT_NODE, (node, delta) => {\n            if (typeof node.data !== \"string\") {\n                return undefined as any // This is how it was written\n            }\n\n            const url = node.data\n            if (isURL(url)) {\n                const sel = (quill as any).selection.savedRange as { index: number; length: number } | null\n                if (sel && sel.length > 0) {\n                    const existing = quill.getContents(sel.index, sel.length)\n                    if (existing.ops.length === 1 && typeof existing.ops[0].insert === \"string\") {\n                        const existingText = existing.ops[0].insert\n                        delta.ops = [{ insert: existingText, attributes: { link: url } }]\n                    }\n                } else {\n                    delta.ops = [{ insert: url, attributes: { link: url } }]\n                }\n            }\n\n            return delta\n        })\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/synchronize-focus-with-server.ts",
    "content": "import * as L from \"lonna\"\nimport * as _ from \"lodash\"\nimport { Board, Id, ItemLocks } from \"../../../common/src/domain\"\nimport { Dispatch } from \"../store/board-store\"\nimport {\n    BoardFocus,\n    getSelectedItemIds,\n    noFocus,\n    removeFromSelection,\n    removeNonExistingFromSelection,\n} from \"./board-focus\"\nimport { componentScope } from \"harmaja\"\nimport { emptySet } from \"../../../common/src/sets\"\n\n/*\n  Centralized module to handle locking/unlocking items, i.e. disallow operating on\n  items on the board when someone else is already doing so.\n\n  Server should have authoritative answer to who is currently holding the lock to a\n  particular item, so if someone else is holding it, stop editing/dragging/selecting\n  that particular item.\n\n  Dispatch lock/unlock requests to server as selection changes: items not selected\n  anymore should be unlocked, and new selected items should be locked.\n\n  item.lock and item.unlock events can be tycitteld freely because the server decides\n  whether to allow the action or not.\n*/\nexport function synchronizeFocusWithServer(\n    board: L.Property<Board>,\n    locks: L.Property<ItemLocks>,\n    sessionId: L.Property<string | null>,\n    dispatch: Dispatch,\n): L.Atom<BoardFocus> {\n    // TODO: not sure if good: item.lock is never dispatched. Instead locker.ts locks items based on\n    // PersistableBoardItemEvents, i.e. relies on the client to send an item.front or similar on selection\n\n    // represents the raw user selection, including possible illegal selections\n    const focusRequest = L.bus<BoardFocus>()\n    type CircumStances = { locks: ItemLocks; sessionId: string | null; board: Board }\n\n    // Circumstances that limit the possible focused selection set\n    const circumstances: L.Property<CircumStances> = L.combineTemplate({\n        locks,\n        sessionId,\n        board,\n    })\n\n    // update focus on new request as well as change to circumstances\n    const events = L.merge(focusRequest, circumstances.pipe(L.changes))\n\n    // selection where illegal (locked) items are removed\n    const resolvedFocus = events.pipe(\n        L.scan(noFocus, (currentFocus, event) => {\n            return narrowFocus(\"status\" in event ? event : currentFocus, circumstances.get())\n        }),\n        L.skipDuplicates<BoardFocus>(_.isEqual, componentScope()),\n    )\n\n    function narrowFocus(focus: BoardFocus, { locks, sessionId: sessionId, board }: CircumStances): BoardFocus {\n        if (!sessionId) return noFocus\n        // TODO consider selected connection in locking as well maybe\n        const itemsWhereSomeoneElseHasLock = new Set(Object.keys(locks).filter((itemId) => locks[itemId] !== sessionId))\n        const nonLocked = removeFromSelection(focus, itemsWhereSomeoneElseHasLock, emptySet())\n        return removeNonExistingFromSelection(nonLocked, board)\n    }\n\n    resolvedFocus.forEach(unlockUnselectedItems)\n\n    // Result atom that allows setting arbitrary focus, but reflects valid selections only\n    return L.atom(resolvedFocus, focusRequest.push)\n\n    function unlockUnselectedItems(f: BoardFocus) {\n        const user = sessionId.get()\n        if (!user) return\n        const l = locks.get()\n        const locksHeld = Object.keys(l).filter((itemId) => l[itemId] === user)\n        const selectedIds = getSelectedItemIds(f)\n        locksHeld.filter((id) => !selectedIds.has(id)).forEach(unlock)\n    }\n\n    function unlock(itemId: Id) {\n        dispatch({ action: \"item.unlock\", boardId: board.get().id, itemId })\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/tool-selection.ts",
    "content": "import { localStorageAtom } from \"./local-storage-atom\"\nimport * as L from \"lonna\"\n\nexport type Tool = \"pan\" | \"select\" | \"connect\" | \"note\" | \"container\" | \"text\" | \"line\"\nexport type ControlSettings = {\n    tool: Tool\n    defaultTool?: Tool\n}\n\nexport type ToolController = ReturnType<typeof ToolController>\n\nexport function ToolController() {\n    const controlSettings = localStorageAtom<ControlSettings>(\"controlSettings\", {\n        tool: \"pan\",\n    })\n    const tool = L.atom(L.view(controlSettings, \"tool\"), (t) => {\n        controlSettings.modify((s) => ({\n            tool: t,\n            defaultTool: t === \"pan\" || t === \"select\" ? t : s.defaultTool,\n        }))\n    })\n    const defaultTool = L.view(controlSettings, (s) => s.defaultTool || \"pan\")\n\n    const useDefaultTool = () => {\n        tool.set(defaultTool.get())\n    }\n\n    return {\n        controlSettings,\n        tool,\n        useDefaultTool,\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/toolbars/BackToAllBoardsLink.tsx",
    "content": "import { h } from \"harmaja\"\nimport { Link } from \"harmaja-router\"\nimport { Routes } from \"../../board-navigation\"\nimport { BackIcon } from \"../../components/Icons\"\nexport function BackToAllBoardsLink() {\n    return (\n        <Link<Routes> href=\"/\" className=\"navigation\">\n            <span className=\"icon\">\n                <BackIcon />\n            </span>\n            All boards\n        </Link>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/toolbars/BoardToolLayer.tsx",
    "content": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { AccessLevel, Board, canWrite, Item, Note } from \"../../../../common/src/domain\"\nimport * as G from \"../../../../common/src/geometry\"\nimport { BoardStore, Dispatch } from \"../../store/board-store\"\nimport { UserSessionState } from \"../../store/user-session-store\"\nimport { BoardCoordinateHelper } from \"../board-coordinates\"\nimport { BoardFocus } from \"../board-focus\"\nimport { ZoomAndScrollControls } from \"../board-scroll-and-zoom\"\nimport { BoardViewMessage } from \"../BoardViewMessage\"\nimport { ToolController } from \"../tool-selection\"\nimport { IS_TOUCHSCREEN } from \"../touchScreen\"\nimport { BackToAllBoardsLink } from \"./BackToAllBoardsLink\"\nimport { MainToolBar } from \"./MainToolBar\"\nimport { MiniMapView } from \"./MiniMapView\"\nimport { UndoRedo } from \"./UndoRedo\"\nimport { ZoomControls } from \"./ZoomControls\"\n\nexport const BoardToolLayer = ({\n    boardStore,\n    coordinateHelper,\n    latestNote,\n    containerElement,\n    sessionState,\n    board,\n    accessLevel,\n    onAdd,\n    toolController,\n    dispatch,\n    viewRect,\n    increaseZoom,\n    decreaseZoom,\n    resetZoom,\n    focus,\n}: {\n    boardStore: BoardStore\n    coordinateHelper: BoardCoordinateHelper\n    latestNote: L.Property<Note>\n    containerElement: L.Atom<HTMLElement | null>\n    sessionState: L.Property<UserSessionState>\n    board: L.Property<Board>\n    accessLevel: L.Property<AccessLevel>\n    onAdd: (i: Item) => void\n    toolController: ToolController\n    dispatch: Dispatch\n    focus: L.Atom<BoardFocus>\n} & ZoomAndScrollControls) => {\n    const touchMoveStart = L.bus<void>()\n    const boardState = boardStore.state\n    const boardAccessStatus = L.view(boardState, (s) => s.status)\n    const tool = toolController.tool\n\n    return (\n        <div className={L.view(accessLevel, (l) => \"tool-layer \" + (canWrite(l) ? \"\" : \" read-only\"))}>\n            <BoardViewMessage {...{ boardAccessStatus, sessionState, board }} />\n\n            <div className=\"navigation-toolbar\">\n                <BackToAllBoardsLink />\n            </div>\n            <MainToolBar\n                {...{\n                    board,\n                    containerElement,\n                    coordinateHelper,\n                    dispatch,\n                    focus,\n                    latestNote,\n                    onAdd,\n                    onTouchMoveStart: touchMoveStart.push,\n                    toolController,\n                    boardStore,\n                }}\n            />\n            <div className=\"undo-redo-toolbar board-tool\">\n                <UndoRedo {...{ dispatch, boardStore }} />\n            </div>\n            <MiniMapView\n                board={board}\n                viewRect={viewRect}\n                increaseZoom={increaseZoom}\n                decreaseZoom={decreaseZoom}\n                resetZoom={resetZoom}\n            />\n\n            {L.view(focus, (f) => {\n                if (f.status !== \"adding\") return null\n                if (IS_TOUCHSCREEN) {\n                    return (\n                        <span className=\"tool-instruction\">\n                            {\"Click on the board to place new item\"}\n                            {f.element}\n                        </span>\n                    )\n                } else {\n                    const style = L.view(coordinateHelper.currentBoardViewPortCoordinates, (p) => ({\n                        position: \"absolute\",\n                        left: `${p.x - 20}px`,\n                        top: `${p.y - 20}px`,\n                        pointerEvents: \"none\",\n                    }))\n                    return (\n                        <span className=\"mouse-cursor-message adding-item\" style={style}>\n                            {f.element}\n                        </span>\n                    )\n                }\n            })}\n\n            {L.view(focus, tool, (f, t) => {\n                if (t !== \"connect\") return null\n                const text =\n                    f.status === \"connection-adding\"\n                        ? \"Finish by clicking on target\"\n                        : \"Click on an item or location to make a connection\"\n                if (IS_TOUCHSCREEN) {\n                    return <span className=\"tool-instruction\">{text}</span>\n                } else {\n                    const style = L.view(coordinateHelper.currentBoardViewPortCoordinates, (p) => ({\n                        position: \"absolute\",\n                        left: `${p.x}px`,\n                        top: `${p.y + 20}px`,\n                        fontSize: \"0.8rem\",\n                        pointerEvents: \"none\",\n                        color: \"#000000aa\",\n                    }))\n                    return (\n                        <span className=\"mouse-cursor-message\" style={style}>\n                            {text}\n                        </span>\n                    )\n                }\n            })}\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/toolbars/MainToolBar.tsx",
    "content": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, Item, Note } from \"../../../../common/src/domain\"\nimport * as G from \"../../../../common/src/geometry\"\nimport { RedoIcon, UndoIcon } from \"../../components/Icons\"\nimport { BoardStore } from \"../../store/board-store\"\nimport { Dispatch } from \"../../store/server-connection\"\nimport { BoardCoordinateHelper } from \"../board-coordinates\"\nimport { BoardFocus, getSelectedConnectionIds, getSelectedItemIds } from \"../board-focus\"\nimport { dispatchDeletion } from \"../item-delete\"\nimport { DND_GHOST_HIDING_IMAGE } from \"../item-drag\"\nimport { dispatchDuplication } from \"../item-duplicate\"\nimport { localStorageAtom } from \"../local-storage-atom\"\nimport { ToolController } from \"../tool-selection\"\nimport { PaletteView } from \"./PaletteView\"\nimport { ToolSelector } from \"./ToolSelector\"\nimport { CRDTStore } from \"../../store/crdt-store\"\n\nexport const MainToolBar = ({\n    coordinateHelper,\n    latestNote,\n    containerElement,\n    onAdd,\n    toolController,\n    focus,\n    dispatch,\n    board,\n    onTouchMoveStart,\n    boardStore,\n}: {\n    coordinateHelper: BoardCoordinateHelper\n    latestNote: L.Property<Note>\n    containerElement: L.Atom<HTMLElement | null>\n    onAdd: (i: Item) => void\n    toolController: ToolController\n    focus: L.Atom<BoardFocus>\n    dispatch: Dispatch\n    board: L.Property<Board>\n    onTouchMoveStart: () => void\n    boardStore: BoardStore\n}) => {\n    type ToolbarPosition = { x?: number; y?: number; orientation: \"vertical\" | \"horizontal\" }\n    const toolbarPosition = localStorageAtom<ToolbarPosition>(\"toolbarPosition\", { orientation: \"horizontal\" })\n    const toolbarEl = L.atom<HTMLElement | null>(null)\n    let cursorPosAtStart: G.Coordinates | null = null\n    let elementStartPos: G.Rect | null = null\n    const onDrag = (e: JSX.DragEvent) => {\n        if (!cursorPosAtStart) return\n        e.preventDefault()\n        const cursorCurrentPos = coordinateHelper.currentPageCoordinates.get()\n\n        const diff = G.subtract(cursorCurrentPos, cursorPosAtStart)\n\n        const minY = 70\n        const minX = 16\n        const boardRect = containerElement.get()!.getBoundingClientRect()\n        const boardViewCenter = boardRect.x + boardRect.width / 2\n        const toolbarCenter = elementStartPos!.x + diff.x + elementStartPos!.width / 2\n        const centerDiff = Math.abs(toolbarCenter - boardViewCenter)\n\n        const newPos = {\n            x: Math.max(elementStartPos!.x + diff.x, minX),\n            y: Math.max(elementStartPos!.y + diff.y, minY),\n        }\n\n        if (newPos.y === minY && centerDiff < 40) {\n            // If on top, fix to center default location\n            toolbarPosition.set({ orientation: \"horizontal\" })\n        } else {\n            toolbarPosition.set({ ...newPos, orientation: newPos.x < 100 ? \"vertical\" : \"horizontal\" })\n        }\n    }\n    const onDragOver = (e: JSX.DragEvent) => {\n        e.preventDefault()\n        // We need to contribute to currentPageCoordinates, otherwise they won't be updated when dragging over the menubar itself\n        coordinateHelper.currentPageCoordinates.set({ x: e.clientX, y: e.clientY })\n    }\n    const onDragStart = (e: JSX.DragEvent) => {\n        if (e.target !== toolbarEl.get()) return // drag started on a palette item\n        cursorPosAtStart = { x: e.clientX, y: e.clientY }\n        e.dataTransfer?.setDragImage(DND_GHOST_HIDING_IMAGE, 0, 0)\n        elementStartPos = toolbarEl.get()!.getBoundingClientRect()\n    }\n    const onDragEnd = (e: JSX.DragEvent) => {\n        cursorPosAtStart = null\n    }\n    const onTouchMove = (e: JSX.TouchEvent) => {\n        e.preventDefault()\n        onTouchMoveStart()\n    }\n    const onTouchStart = (e: JSX.TouchEvent) => {\n        e.preventDefault()\n    }\n    const toolbarStyle = L.view(toolbarPosition, (p) => ({\n        top: p.y || undefined,\n        left: p.x || undefined,\n        transform: p.x !== undefined ? \"none\" : undefined,\n    }))\n\n    return (\n        <div\n            className={L.view(toolbarPosition, (o) => `main-toolbar board-tool ${o.orientation}`)}\n            style={toolbarStyle}\n            ref={toolbarEl.set}\n            draggable=\"true\"\n            onDragOver={onDragOver}\n            onDrag={onDrag}\n            onDragStart={onDragStart}\n            onDragEnd={onDragEnd}\n            onTouchMove={onTouchMove}\n            onTouchStart={onTouchStart}\n        >\n            <PaletteView {...{ latestNote, addItem: onAdd, focus, tool: toolController.tool, board }} />\n            <ToolSelector {...{ toolController }} />\n            <DeleteIcon {...{ focus, dispatch, board }} />\n            <DuplicateIcon {...{ focus, dispatch, board, crdtStore: boardStore.crdtStore }} />\n            <UndoToolIcon {...{ boardStore, dispatch }} />\n            <RedoToolIcon {...{ boardStore, dispatch }} />\n        </div>\n    )\n}\n\ntype UndoProps = {\n    dispatch: Dispatch\n    boardStore: BoardStore\n}\nconst UndoToolIcon = ({ dispatch, boardStore }: UndoProps) => {\n    const undo = () => dispatch({ action: \"ui.undo\" })\n    return (\n        <span className=\"tool undo\" title=\"Undo\" onMouseDown={undo} onTouchStart={undo}>\n            <span className=\"icon\">\n                <UndoIcon enabled={boardStore.canUndo} />\n            </span>\n            <span className=\"text\">Undo</span>\n        </span>\n    )\n}\n\nconst RedoToolIcon = ({ dispatch, boardStore }: UndoProps) => {\n    const redo = () => dispatch({ action: \"ui.redo\" })\n    return (\n        <span className=\"tool redo\" title=\"Redo\" onMouseDown={redo}>\n            <span className=\"icon\">\n                <RedoIcon enabled={boardStore.canRedo} />\n            </span>\n            <span className=\"text\">Redo</span>\n        </span>\n    )\n}\n\ntype DeleteProps = {\n    focus: L.Atom<BoardFocus>\n    board: L.Property<Board>\n    dispatch: Dispatch\n}\n\nconst DeleteIcon = ({ focus, board, dispatch }: DeleteProps) => {\n    const enabled = L.view(focus, (f) => getSelectedConnectionIds(f).size > 0 || getSelectedItemIds(f).size > 0)\n    const deleteItem = () => dispatchDeletion(board.get().id, focus.get(), dispatch)\n    return (\n        <span className=\"tool\" title=\"Delete selected item(s)\" onMouseDown={deleteItem} onTouchStart={deleteItem}>\n            <span className={L.view(enabled, (e) => (e ? \"icon\" : \"icon disabled\"))}>\n                <svg viewBox=\"0 0 24 24\">\n                    <path\n                        fill=\"currentColor\"\n                        d=\"M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M7,6H17V19H7V6M9,8V17H11V8H9M13,8V17H15V8H13Z\"\n                    />\n                </svg>\n            </span>\n            <span className=\"text\">Delete</span>\n        </span>\n    )\n}\n\ntype DuplicateProps = {\n    focus: L.Atom<BoardFocus>\n    board: L.Property<Board>\n    dispatch: Dispatch\n    crdtStore: CRDTStore\n}\n\nconst DuplicateIcon = ({ focus, board, dispatch, crdtStore }: DuplicateProps) => {\n    const enabled = L.view(focus, (f) => getSelectedConnectionIds(f).size > 0 || getSelectedItemIds(f).size > 0)\n    const duplicateItems = () => dispatchDuplication(focus, board.get(), dispatch, crdtStore)\n    return (\n        <span\n            className=\"tool duplicate\"\n            title=\"Clone selected item(s)\"\n            onMouseDown={duplicateItems}\n            onTouchStart={duplicateItems}\n        >\n            <span className={L.view(enabled, (e) => (e ? \"icon\" : \"icon disabled\"))}>\n                <svg viewBox=\"0 0 22 21\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <title>Make a copy</title>\n                    <rect\n                        x=\"6.56582\"\n                        y=\"5.86245\"\n                        width=\"14.0013\"\n                        height=\"14.0013\"\n                        rx=\"1.4\"\n                        fill=\"currentColor\"\n                        fill-opacity=\"0.1\"\n                        stroke=\"currentColor\"\n                        stroke-width=\"1.2\"\n                    />\n                    <path\n                        d=\"M3.6805 15.7398H3.18848C2.08391 15.7398 1.18848 14.8444 1.18848 13.7398V3.18433C1.18848 2.07976 2.08391 1.18433 3.18848 1.18433H13.8317C14.9363 1.18433 15.8317 2.07976 15.8317 3.18433V3.76324\"\n                        stroke=\"currentColor\"\n                        stroke-width=\"1.2\"\n                        stroke-linecap=\"round\"\n                    />\n                </svg>\n            </span>\n            <span className=\"text\">Clone</span>\n        </span>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/toolbars/MiniMapView.tsx",
    "content": "import { h, ListView } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, Item } from \"../../../../common/src/domain\"\nimport { Rect } from \"../../../../common/src/geometry\"\nimport { DND_GHOST_HIDING_IMAGE } from \"../item-drag\"\nimport { onSingleTouch } from \"../touchScreen\"\nimport { ZoomControls } from \"./ZoomControls\"\nimport { ZoomAndScrollControls } from \"../board-scroll-and-zoom\"\n\nexport const MiniMapView = ({\n    viewRect,\n    board,\n    increaseZoom,\n    decreaseZoom,\n    resetZoom,\n}: { viewRect: L.Atom<Rect>; board: L.Property<Board> } & Pick<\n    ZoomAndScrollControls,\n    \"increaseZoom\" | \"decreaseZoom\" | \"resetZoom\"\n>) => {\n    const minimapWidthPx = 125\n    const minimapDimensions = L.view(board, (rect) => {\n        return { width: minimapWidthPx, height: (minimapWidthPx / rect.width) * rect.height }\n    })\n    const minimapAspectRatio = L.view(minimapDimensions, board, (mm, b) => mm.width / b.width)\n    const minimapStyle = L.view(minimapDimensions, (d) => ({ width: d.width + \"px\", height: d.height + \"px\" }))\n\n    const viewAreaStyle = L.view(viewRect, minimapDimensions, board, (vr, mm, b) => {\n        return {\n            width: `${(vr.width * mm.width) / b.width}px`,\n            height: `${(vr.height * mm.height) / b.height}px`,\n            left: `${Math.max(0, (vr.x * mm.width) / b.width)}px`,\n            top: `${Math.max(0, (vr.y * mm.height) / b.height)}px`,\n        }\n    })\n    let startDrag: JSX.DragEvent | null = null\n    let startViewRect: Rect | null = null\n    function onDragEnd(e: JSX.DragEvent) {\n        startDrag = null\n    }\n    function onDragStart(e: JSX.DragEvent) {\n        startDrag = e\n        startViewRect = viewRect.get()\n        e.dataTransfer?.setDragImage(DND_GHOST_HIDING_IMAGE, 0, 0)\n    }\n    function onDragOver(e: JSX.DragEvent) {\n        if (startDrag && startViewRect) {\n            const xDiff = e.clientX - startDrag.clientX\n            const yDiff = e.clientY - startDrag.clientY\n            const ar = minimapAspectRatio.get()\n            const newRect = { ...startViewRect, x: startViewRect.x + xDiff / ar, y: startViewRect.y + yDiff / ar }\n            viewRect.set(newRect)\n        }\n    }\n    const contentElement = L.atom<HTMLDivElement | null>(null)\n    function onClick(e: JSX.MouseEvent | Touch) {\n        const elementArea = contentElement.get()!.getBoundingClientRect()\n        const ar = minimapAspectRatio.get()\n        const x = (e.clientX - elementArea.x) / ar\n        const y = (e.clientY - elementArea.y) / ar\n        viewRect.modify((rect) => ({\n            ...rect,\n            x: x - rect.width / 2,\n            y: y - rect.height / 2,\n        }))\n    }\n\n    function onTouchStart(e: JSX.TouchEvent) {\n        e.preventDefault()\n        onSingleTouch(e, (touch) => onClick(touch))\n    }\n    function onTouchMove(e: JSX.TouchEvent) {\n        e.preventDefault()\n        onSingleTouch(e, (touch) => onClick(touch))\n    }\n    function onTouchEnd(e: JSX.TouchEvent) {\n        e.preventDefault()\n        onSingleTouch(e, (touch) => onClick(touch))\n    }\n\n    return (\n        <div\n            className=\"minimap\"\n            style={minimapStyle}\n            onDragOver={onDragOver}\n            onTouchStart={onTouchStart}\n            onTouchMove={onTouchMove}\n            onTouchEnd={onTouchEnd}\n            onClick={onClick}\n        >\n            <div className=\"content\" ref={contentElement.set}>\n                <ListView\n                    observable={L.view(L.view(board, \"items\"), Object.values)}\n                    renderObservable={renderItem}\n                    getKey={(i) => i.id}\n                />\n                <div\n                    className=\"viewarea\"\n                    draggable={true}\n                    onDragStart={onDragStart}\n                    onDragEnd={onDragEnd}\n                    style={viewAreaStyle}\n                />\n            </div>\n            <div className=\"zoom-toolbar board-tool\">\n                <ZoomControls {...{ increaseZoom, decreaseZoom, resetZoom, viewRect }} />\n            </div>\n        </div>\n    )\n\n    function renderItem(id: string, item: L.Property<Item>) {\n        const style = L.view(item, minimapAspectRatio, (item, ratio) => ({\n            left: item.x * ratio + \"px\",\n            top: item.y * ratio + \"px\",\n            width: item.width * ratio + \"px\",\n            height: item.height * ratio + \"px\",\n        }))\n        const type = item.get().type\n        return <span className={\"item \" + type} style={style} />\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/toolbars/PaletteView.tsx",
    "content": "import { h, Fragment, HarmajaChild } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, Item, newContainer, newSimilarNote, newText, Note } from \"../../../../common/src/domain\"\nimport { BoardFocus } from \"../board-focus\"\nimport { Tool } from \"../tool-selection\"\n\nexport const PaletteView = ({\n    latestNote,\n    addItem,\n    focus,\n    tool,\n    board,\n}: {\n    latestNote: L.Property<Note>\n    addItem: (item: Item) => void\n    focus: L.Atom<BoardFocus>\n    tool: L.Atom<Tool>\n    board: L.Property<Board>\n}) => {\n    return (\n        <>\n            <NewNote {...{ addItem, latestNote, focus, tool }} />\n            <NewContainer {...{ addItem, focus, tool, board }} />\n            <NewText {...{ addItem, focus, tool, board }} />\n        </>\n    )\n}\n\nexport const NewText = ({\n    addItem: onAdd,\n    focus,\n    tool,\n    board,\n}: {\n    addItem: (i: Item) => void\n    focus: L.Atom<BoardFocus>\n    tool: L.Atom<Tool>\n    board: L.Property<Board>\n}) => {\n    const svg = () => (\n        <svg viewBox=\"0 0 44 49\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n            <circle cx=\"36\" cy=\"8.5\" r=\"8\" fill=\"#2F80ED\" />\n            <path\n                d=\"M38.5309 8.816V8.002H36.4079V5.879H35.5939V8.002H33.4709V8.816H35.5939V10.939H36.4079V8.816H38.5309Z\"\n                fill=\"white\"\n            />\n            <path\n                d=\"M10.0695 17.78H12.5015L21.7815 40.5H18.7095L16.0215 33.844H6.4855L3.8295 40.5H0.7575L10.0695 17.78ZM15.3815 31.604L11.2855 21.108L7.0615 31.604H15.3815ZM22.8038 35.668C22.8038 34.6013 23.1024 33.684 23.6998 32.916C24.3184 32.1267 25.1611 31.5187 26.2278 31.092C27.2944 30.6653 28.5318 30.452 29.9398 30.452C30.6864 30.452 31.4758 30.516 32.3078 30.644C33.1398 30.7507 33.8758 30.9213 34.5158 31.156V29.94C34.5158 28.66 34.1318 27.6573 33.3638 26.932C32.5958 26.1853 31.5078 25.812 30.0998 25.812C29.1824 25.812 28.2971 25.9827 27.4438 26.324C26.6118 26.644 25.7264 27.1133 24.7878 27.732L23.7638 25.748C24.8518 25.0013 25.9398 24.4467 27.0278 24.084C28.1158 23.7 29.2464 23.508 30.4198 23.508C32.5531 23.508 34.2384 24.1053 35.4758 25.3C36.7131 26.4733 37.3318 28.116 37.3318 30.228V37.3C37.3318 37.6413 37.3958 37.8973 37.5238 38.068C37.6731 38.2173 37.9078 38.3027 38.2278 38.324V40.5C37.9504 40.5427 37.7051 40.5747 37.4918 40.596C37.2998 40.6173 37.1398 40.628 37.0118 40.628C36.3504 40.628 35.8491 40.4467 35.5078 40.084C35.1878 39.7213 35.0064 39.3373 34.9638 38.932L34.8998 37.876C34.1744 38.8147 33.2251 39.54 32.0518 40.052C30.8784 40.564 29.7158 40.82 28.5638 40.82C27.4544 40.82 26.4624 40.596 25.5878 40.148C24.7131 39.6787 24.0304 39.06 23.5398 38.292C23.0491 37.5027 22.8038 36.628 22.8038 35.668ZM33.6838 36.852C33.9398 36.5533 34.1424 36.2547 34.2918 35.956C34.4411 35.636 34.5158 35.3693 34.5158 35.156V33.076C33.8544 32.82 33.1611 32.628 32.4358 32.5C31.7104 32.3507 30.9958 32.276 30.2917 32.276C28.8624 32.276 27.6998 32.564 26.8038 33.14C25.9291 33.6947 25.4918 34.4627 25.4918 35.444C25.4918 35.9773 25.6304 36.5 25.9078 37.012C26.2064 37.5027 26.6331 37.908 27.1878 38.228C27.7638 38.548 28.4678 38.708 29.2998 38.708C30.1744 38.708 31.0064 38.5373 31.7958 38.196C32.5851 37.8333 33.2144 37.3853 33.6838 36.852Z\"\n                fill=\"black\"\n            />\n        </svg>\n    )\n    return (\n        <NewItem\n            type=\"text\"\n            title=\"Text\"\n            tooltip=\"Drag to add new text area\"\n            svg={svg}\n            focus={focus}\n            createItem={() => newText(board.get().crdt)}\n            addItem={onAdd}\n            tool={tool}\n        />\n    )\n}\n\nfunction lightenDarkenColor(col: string, amt: number) {\n    var num = parseInt(col.replace(\"#\", \"\"), 16)\n    var r = (num >> 16) + amt\n    var b = ((num >> 8) & 0x00ff) + amt\n    var g = (num & 0x0000ff) + amt\n    var newColor = g | (b << 8) | (r << 16)\n    return \"#\" + newColor.toString(16)\n}\n\nexport const NewNote = ({\n    latestNote,\n    addItem: onAdd,\n    focus,\n    tool,\n}: {\n    latestNote: L.Property<Note>\n    addItem: (i: Item) => void\n    focus: L.Atom<BoardFocus>\n    tool: L.Atom<Tool>\n}) => {\n    const color = L.view(latestNote, \"color\")\n    const cornerColor = L.view(color, (c) => lightenDarkenColor(c, -20))\n\n    const noteColor = color\n    const svg = () => (\n        <svg viewBox=\"0 0 44 49\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n            <path\n                d=\"M0 9.5C0 8.94771 0.447715 8.5 1 8.5H39C39.5523 8.5 40 8.94772 40 9.5V32.1073C40 32.3597 39.9045 32.6028 39.7328 32.7878L33.5 39.5L25.296 48.1866C25.1071 48.3866 24.8441 48.5 24.569 48.5H1C0.447716 48.5 0 48.0523 0 47.5V9.5Z\"\n                fill={noteColor}\n            />\n            <path d=\"M26 32.5H40L25 48.5V33.5C25 32.9477 25.4477 32.5 26 32.5Z\" fill={cornerColor} />\n            <circle cx=\"36\" cy=\"8.5\" r=\"8\" fill=\"#2F80ED\" />\n            <path\n                d=\"M38.5309 8.816V8.002H36.4079V5.879H35.5939V8.002H33.4709V8.816H35.5939V10.939H36.4079V8.816H38.5309Z\"\n                fill=\"white\"\n            />\n        </svg>\n    )\n    return (\n        <NewItem\n            type=\"note\"\n            title=\"Note\"\n            tooltip=\"Drag to add new text note\"\n            svg={svg}\n            focus={focus}\n            createItem={() => newSimilarNote(latestNote.get())}\n            addItem={onAdd}\n            tool={tool}\n        />\n    )\n}\n\nexport const NewContainer = ({\n    addItem: onAdd,\n    focus,\n    tool,\n    board,\n}: {\n    addItem: (i: Item) => void\n    focus: L.Atom<BoardFocus>\n    tool: L.Atom<Tool>\n    board: L.Property<Board>\n}) => {\n    const svg = () => (\n        <svg viewBox=\"0 0 44 49\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n            <path\n                d=\"M0.5 9.5C0.5 9.22386 0.723857 9 1 9H39C39.2761 9 39.5 9.22386 39.5 9.5V32.5V47.5C39.5 47.7761 39.2761 48 39 48H25H1C0.723858 48 0.5 47.7761 0.5 47.5V9.5Z\"\n                fill=\"white\"\n                stroke=\"#BDBDBD\"\n            />\n            <circle cx=\"36\" cy=\"8.5\" r=\"8\" fill=\"#2F80ED\" />\n            <path\n                d=\"M38.5309 8.816V8.002H36.4079V5.879H35.5939V8.002H33.4709V8.816H35.5939V10.939H36.4079V8.816H38.5309Z\"\n                fill=\"white\"\n            />\n        </svg>\n    )\n    return (\n        <NewItem\n            type=\"container\"\n            title=\"Area\"\n            tooltip=\"Drag to add new area for organizing items\"\n            svg={svg}\n            focus={focus}\n            createItem={() => newContainer(board.get().crdt)}\n            addItem={onAdd}\n            tool={tool}\n        />\n    )\n}\n\nexport const NewItem = ({\n    type,\n    title,\n    tooltip,\n    createItem,\n    focus,\n    svg,\n    addItem,\n    tool,\n}: {\n    type: \"note\" | \"container\" | \"text\"\n    title: string\n    tooltip: string\n    createItem: () => Item\n    focus: L.Atom<BoardFocus>\n    svg: () => HarmajaChild\n    addItem: (i: Item) => void\n    tool: L.Atom<Tool>\n}) => {\n    const startAdd = (e: JSX.UIEvent) => {\n        focus.set({ status: \"adding\", element: svg(), item: createItem() })\n        tool.set(type)\n        e.preventDefault()\n        e.stopPropagation()\n    }\n    const onEndDrag = () => {\n        addItem(createItem())\n    }\n    return (\n        <span\n            className={L.view(tool, (t) => `new-item ${type} ${t === type ? \"active\" : \"\"}`)}\n            onClick={startAdd}\n            onTouchStart={startAdd}\n        >\n            <span\n                className=\"icon\"\n                data-test={`palette-new-${type}`}\n                title={tooltip}\n                onDragEnd={onEndDrag}\n                draggable={true}\n            >\n                {svg()}\n            </span>\n            <span className=\"text\">{title}</span>\n        </span>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/toolbars/ToolSelector.tsx",
    "content": "import { Fragment, h, HarmajaChild } from \"harmaja\"\nimport { capitalize } from \"lodash\"\nimport * as L from \"lonna\"\nimport { Color } from \"../../../../common/src/domain\"\nimport { black } from \"../../components/UIColors\"\nimport { Tool, ToolController } from \"../tool-selection\"\nimport { IS_TOUCHSCREEN } from \"../touchScreen\"\n\nexport const ToolSelector = ({ toolController }: { toolController: ToolController }) => {\n    const tool = toolController.tool\n    return (\n        <>\n            {!IS_TOUCHSCREEN && (\n                <ToolIcon\n                    {...{\n                        name: \"select\",\n                        tooltip: \"Select tool\",\n                        currentTool: tool,\n                        svg: (c) => (\n                            <svg viewBox=\"0 0 24 24\">\n                                <path fill={c} d=\"M7,2l12,11.2l-5.8,0.5l3.3,7.3l-2.2,1l-3.2-7.4L7,18.5V2\" />\n                            </svg>\n                        ),\n                    }}\n                />\n            )}\n            {!IS_TOUCHSCREEN && (\n                <ToolIcon\n                    {...{\n                        name: \"pan\",\n                        tooltip: \"Pan tool\",\n                        currentTool: tool,\n                        svg: (c) => (\n                            <svg viewBox=\"0 0 34 35\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                                <path\n                                    d=\"M17.5657 0.934315C17.2533 0.621895 16.7467 0.621895 16.4343 0.934315L11.3431 6.02548C11.0307 6.3379 11.0307 6.84443 11.3431 7.15685C11.6556 7.46927 12.1621 7.46927 12.4745 7.15685L17 2.63137L21.5255 7.15685C21.8379 7.46927 22.3444 7.46927 22.6569 7.15685C22.9693 6.84443 22.9693 6.3379 22.6569 6.02548L17.5657 0.934315ZM16.4343 34.0657C16.7467 34.3781 17.2533 34.3781 17.5657 34.0657L22.6569 28.9745C22.9693 28.6621 22.9693 28.1556 22.6569 27.8431C22.3444 27.5307 21.8379 27.5307 21.5255 27.8431L17 32.3686L12.4745 27.8431C12.1621 27.5307 11.6556 27.5307 11.3431 27.8431C11.0307 28.1556 11.0307 28.6621 11.3431 28.9745L16.4343 34.0657ZM16.2 1.5V33.5H17.8V1.5H16.2Z\"\n                                    fill={c}\n                                />\n                                <path\n                                    d=\"M33.5657 18.0657C33.8781 17.7533 33.8781 17.2467 33.5657 16.9343L28.4745 11.8431C28.1621 11.5307 27.6556 11.5307 27.3431 11.8431C27.0307 12.1556 27.0307 12.6621 27.3431 12.9745L31.8686 17.5L27.3431 22.0255C27.0307 22.3379 27.0307 22.8444 27.3431 23.1569C27.6556 23.4693 28.1621 23.4693 28.4745 23.1569L33.5657 18.0657ZM0.434315 16.9343C0.121895 17.2467 0.121895 17.7533 0.434315 18.0657L5.52548 23.1569C5.8379 23.4693 6.34443 23.4693 6.65685 23.1569C6.96927 22.8444 6.96927 22.3379 6.65685 22.0255L2.13137 17.5L6.65685 12.9745C6.96927 12.6621 6.96927 12.1556 6.65685 11.8431C6.34443 11.5307 5.8379 11.5307 5.52548 11.8431L0.434315 16.9343ZM33 16.7L1 16.7L1 18.3L33 18.3L33 16.7Z\"\n                                    fill={c}\n                                />\n                            </svg>\n                        ),\n                    }}\n                />\n            )}\n            <ToolIcon\n                {...{\n                    name: \"connect\",\n                    tooltip: \"Connect tool\",\n                    currentTool: tool,\n                    svg: (c) => (\n                        <svg viewBox=\"0 -9 35 35\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <path\n                                d=\"M31.4589 25.5L25.1773 17.9585L34.8493 16.2892L31.4589 25.5ZM2.3871 3.04943C9.59422 1.52064 15.8128 1.84779 20.7217 4.27343C25.6572 6.71218 29.1382 11.2072 30.9758 17.7302L29.3395 18.1911C27.6044 12.0318 24.3875 7.98098 19.9687 5.79752C15.5233 3.60094 9.73115 3.22942 2.73986 4.71243L2.3871 3.04943Z\"\n                                fill={c}\n                            />\n                            <circle\n                                r=\"2.25\"\n                                transform=\"matrix(1 0 0 -1 3.54199 3.2746)\"\n                                fill=\"white\"\n                                stroke={c}\n                                stroke-width=\"1.5\"\n                            />\n                            <circle\n                                r=\"2.3\"\n                                transform=\"matrix(1 0 0 -1 21.7061 5.97119)\"\n                                fill=\"white\"\n                                stroke={c}\n                                stroke-width=\"1.4\"\n                            />\n                        </svg>\n                    ),\n                }}\n            />\n            <ToolIcon\n                {...{\n                    name: \"line\",\n                    tooltip: \"Line tool\",\n                    currentTool: tool,\n                    svg: (c) => (\n                        <svg viewBox=\"0 0 24 24\">\n                            <path fill-rule=\"evenodd\" d=\"M4 11h16v2H4z\"></path>\n                        </svg>\n                    ),\n                }}\n            />\n        </>\n    )\n}\n\nconst ToolIcon = ({\n    name,\n    tooltip,\n    currentTool,\n    svg,\n}: {\n    name: Tool\n    tooltip: string\n    currentTool: L.Atom<Tool>\n    svg: (c: Color) => HarmajaChild\n}) => {\n    const selectTool = () => currentTool.set(name)\n    return (\n        <span\n            className={L.view(currentTool, (s) => (s === name ? \"tool active\" : \"tool\") + \" \" + name)}\n            title={tooltip}\n            onClick={selectTool}\n            onTouchStart={selectTool}\n        >\n            <span className=\"icon\">{L.view(currentTool, (s) => svg(black))}</span>\n            <span className=\"text\">{capitalize(name)}</span>\n        </span>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/toolbars/UndoRedo.tsx",
    "content": "import { h } from \"harmaja\"\nimport { RedoIcon, UndoIcon } from \"../../components/Icons\"\nimport { BoardStore, Dispatch } from \"../../store/board-store\"\n\nexport function UndoRedo({ dispatch, boardStore }: { dispatch: Dispatch; boardStore: BoardStore }) {\n    return (\n        <div className=\"undo-redo\">\n            <span className=\"icon\" title=\"Undo\" onClick={() => dispatch({ action: \"ui.undo\" })}>\n                <UndoIcon enabled={boardStore.canUndo} />\n            </span>\n            <span className=\"icon\" title=\"Redo\" onClick={() => dispatch({ action: \"ui.redo\" })}>\n                <RedoIcon enabled={boardStore.canRedo} />\n            </span>\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/toolbars/ZoomControls.tsx",
    "content": "import { h } from \"harmaja\"\nimport { ResetZoomIcon, ZoomInIcon, ZoomOutIcon } from \"../../components/Icons\"\nimport { ZoomAndScrollControls } from \"../board-scroll-and-zoom\"\n\nexport function ZoomControls({\n    increaseZoom,\n    decreaseZoom,\n    resetZoom,\n}: Pick<ZoomAndScrollControls, \"increaseZoom\" | \"decreaseZoom\" | \"resetZoom\">) {\n    return (\n        <span className=\"zoom-controls\" onClick={(e) => e.stopPropagation()}>\n            <span className=\"icon\" title=\"Zoom in\" onClick={() => increaseZoom(\"preserveCenter\")}>\n                <ZoomInIcon />\n            </span>\n            <span className=\"icon\" title=\"Reset zoom\" onClick={() => resetZoom(\"preserveCenter\")}>\n                <ResetZoomIcon />\n            </span>\n            <span className=\"icon\" title=\"Zoom out\" onClick={() => decreaseZoom(\"preserveCenter\")}>\n                <ZoomOutIcon />\n            </span>\n        </span>\n    )\n}\n"
  },
  {
    "path": "frontend/src/board/touchScreen.ts",
    "content": "export const IS_TOUCHSCREEN = \"ontouchstart\" in window\n\nexport function getSingleTouch(e: TouchEvent | JSX.TouchEvent) {\n    if (e.touches.length === 1) return e.touches[0]\n    return null\n}\n\nexport function isSingleTouch(e: TouchEvent | JSX.TouchEvent) {\n    return getSingleTouch(e) !== null\n}\n\nexport function onSingleTouch(e: TouchEvent | JSX.TouchEvent, callback: (touch: Touch) => void) {\n    const touch = getSingleTouch(e)\n    if (touch) {\n        callback(touch)\n    }\n}\n"
  },
  {
    "path": "frontend/src/board/zIndices.ts",
    "content": "import { Item } from \"../../../common/src/domain\"\n\nexport const Z_CONTAINERS_UP_TO = 1000000\nexport const Z_CONNECTIONS = 200000000\nexport const Z_ITEMS_FROM = Z_CONTAINERS_UP_TO + 10\n\nexport function itemZIndex(item: Item) {\n    return Math.floor(1000000 - item.width * item.height * 10) + item.z\n}\n"
  },
  {
    "path": "frontend/src/board/zoom-shortcuts.ts",
    "content": "import { ZoomAndScrollControls } from \"./board-scroll-and-zoom\"\nimport { controlKey, installKeyboardShortcut } from \"./keyboard-shortcuts\"\nimport * as L from \"lonna\"\n\nexport function installZoomKeyboardShortcuts({ resetZoom, increaseZoom, decreaseZoom }: ZoomAndScrollControls) {\n    installKeyboardShortcut(controlKey(\"+\"), () => increaseZoom(\"preserveCursor\"))\n    installKeyboardShortcut(controlKey(\"-\"), () => decreaseZoom(\"preserveCursor\"))\n    installKeyboardShortcut(controlKey(\"0\"), () => resetZoom(\"preserveCursor\"))\n}\n"
  },
  {
    "path": "frontend/src/board-navigation.ts",
    "content": "import { HarmajaRouter, Navigator } from \"harmaja-router\"\nimport * as L from \"lonna\"\nimport { Board, BoardStub, EventFromServer } from \"../../common/src/domain\"\nimport \"./app.scss\"\nimport { Dispatch } from \"./store/server-connection\"\n\nexport const BOARD_PATH = \"/b/:boardId\"\nexport const ROOT_PATH = \"/\"\n\nexport const Routes = {\n    [ROOT_PATH]: () => ({ page: \"Dashboard\" as const }),\n    [BOARD_PATH]: ({ boardId }: { boardId: string }) => ({ page: \"Board\" as const, boardId }),\n    \"\": () => ({ page: \"NotFound\" as const }),\n}\nexport type Routes = typeof Routes\n\nexport function BoardNavigation() {\n    const result = HarmajaRouter(Routes)\n\n    const nicknameFromURL = new URLSearchParams(location.search).get(\"nickname\")\n    if (nicknameFromURL) {\n        localStorage.nickname = nicknameFromURL\n        const search = new URLSearchParams(location.search)\n        search.delete(\"nickname\")\n        document.location.search = search.toString()\n    }\n\n    const boardId = L.view(result, (r) => (r.page === \"Board\" ? r.boardId : undefined))\n\n    return {\n        boardId,\n        page: result,\n    }\n}\n\nexport function createBoardAndNavigate(\n    newBoard: Board | BoardStub,\n    dispatch: Dispatch,\n    navigator: Navigator<Routes>,\n    serverEvents: L.EventStream<EventFromServer>,\n) {\n    dispatch({ action: \"board.add\", payload: newBoard })\n    serverEvents.forEach(\n        (e) => e.action === \"board.add.ack\" && navigator.navigateByParams(BOARD_PATH, { boardId: newBoard.id }),\n    )\n}\n"
  },
  {
    "path": "frontend/src/components/BoardAccessPolicyEditor.tsx",
    "content": "import { Fragment, h, ListView } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { AccessListEntry, BoardAccessPolicy, BoardAccessPolicyDefined } from \"../../../common/src/domain\"\nimport { Checkbox, TextInput } from \"../components/components\"\nimport { defaultAccessPolicy, LoggedIn } from \"../store/user-session-store\"\n\ntype BoardAccessPolicyEditorProps = {\n    accessPolicy: L.Atom<BoardAccessPolicy>\n    user: LoggedIn\n}\nexport const BoardAccessPolicyEditor = ({ accessPolicy, user }: BoardAccessPolicyEditorProps) => {\n    const originalPolicy = accessPolicy.get()\n    const restrictAccessToggle = L.atom(!!originalPolicy && !originalPolicy.publicWrite)\n    restrictAccessToggle.onChange((restrict) => {\n        accessPolicy.set(defaultAccessPolicy(user, restrict))\n    })\n\n    return (\n        <div className=\"board-access-editor\">\n            <div className=\"restrict-toggle\">\n                <Checkbox checked={restrictAccessToggle}>\n                    Restrict access to specific domains / email addresses\n                </Checkbox>\n            </div>\n            <div className=\"domain-restrict-details\">\n                {L.view(\n                    accessPolicy,\n                    (a) => !!a && !a.publicWrite,\n                    (a) =>\n                        a && (\n                            <BoardAccessPolicyDetailsEditor\n                                accessPolicy={accessPolicy as L.Atom<BoardAccessPolicyDefined>}\n                                user={user}\n                            />\n                        ),\n                )}\n            </div>\n        </div>\n    )\n}\n\nconst BoardAccessPolicyDetailsEditor = ({\n    accessPolicy,\n    user,\n}: {\n    accessPolicy: L.Atom<BoardAccessPolicyDefined>\n    user: LoggedIn\n}) => {\n    const allowList = L.view(accessPolicy, \"allowList\")\n    const inputRef = L.atom<HTMLInputElement | null>(null)\n    const allowPublicReadRaw = L.view(accessPolicy, \"publicRead\")\n    const allowPublicRead = L.atom<boolean>(\n        L.view(allowPublicReadRaw, (r) => !!r),\n        allowPublicReadRaw.set,\n    )\n    const currentInputText = L.atom(\"\")\n    const currentInputValid = L.view(currentInputText, (text) => parseAccessListEntry(text) !== null)\n\n    function parseAccessListEntry(input: string): AccessListEntry | null {\n        // LMAO at this validation\n        return input.includes(\"@\")\n            ? { email: input, access: \"read-write\" }\n            : input.includes(\".\")\n            ? { domain: input, access: \"read-write\" }\n            : null\n    }\n\n    inputRef.forEach((t) => {\n        if (t) {\n            // Autofocus email/domain input field for better UX\n            t.focus()\n        }\n    })\n\n    function addToAllowListIfValid(input: string) {\n        const entry: AccessListEntry | null = parseAccessListEntry(input)\n        if (entry) {\n            allowList.modify((w) => [entry, ...w])\n            currentInputText.set(\"\")\n        }\n    }\n\n    return (\n        <>\n            <div className=\"input-and-button\">\n                <TextInput ref={inputRef} value={currentInputText} type=\"text\" placeholder=\"Enter email or domain\" />\n                <button\n                    onClick={(e) => {\n                        e.preventDefault()\n                        addToAllowListIfValid(currentInputText.get())\n                    }}\n                    disabled={L.not(currentInputValid)}\n                >\n                    Add\n                </button>\n            </div>\n\n            <ListView\n                observable={allowList}\n                renderItem={(entry) => {\n                    return (\n                        <div className=\"input-and-button\">\n                            <div className=\"filled-entry\">\n                                {\"domain\" in entry\n                                    ? `Allowing everyone with an email address ending in ${entry.domain}`\n                                    : `Allowing user ${entry.email} (${entry.access})`}\n                            </div>\n                            <button\n                                disabled={\"email\" in entry ? entry.email === user.email : false}\n                                onClick={() => allowList.modify((w) => w.filter((e) => e !== entry))}\n                            >\n                                Remove\n                            </button>\n                        </div>\n                    )\n                }}\n            />\n\n            <p className=\"allow-public-read\">\n                <Checkbox checked={allowPublicRead}>Anyone with the link can view</Checkbox>\n            </p>\n        </>\n    )\n}\n"
  },
  {
    "path": "frontend/src/components/BoardCrdtModeSelector.tsx",
    "content": "import { Fragment, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Checkbox } from \"./components\"\n\ntype BoardCrdtModeSelectorProps = {\n    useCollaborativeEditing: L.Atom<boolean>\n}\nexport const BoardCrdtModeSelector = ({ useCollaborativeEditing }: BoardCrdtModeSelectorProps) => {\n    return (\n        <div className=\"board-access-editor\">\n            <div className=\"restrict-toggle\">\n                <Checkbox checked={useCollaborativeEditing}>Experimental: use collaborative text editor</Checkbox>\n            </div>\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/components/EditableSpan.tsx",
    "content": "import * as H from \"harmaja\"\nimport * as L from \"lonna\"\nimport { h, HarmajaOutput } from \"harmaja\"\nimport { isFirefox } from \"./browser\"\n\nexport type EditableSpanProps = {\n    value: L.Atom<string>\n    editingThis: L.Atom<boolean>\n    showIcon?: boolean\n    commit?: () => void\n    cancel?: () => void\n} & JSX.DetailedHTMLProps<JSX.HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>\n\nfunction clearSelection() {\n    if (!isFirefox) {\n        // Don't clear selection on Firefox, because for an unknown reason, the \"selectAll\" functionality below breaks after first clearSelection call.\n        window.getSelection()?.removeAllRanges()\n    }\n}\n\nexport const EditableSpan = (props: EditableSpanProps) => {\n    let { value, editingThis, commit, cancel, ...rest } = props\n    const nameElement = L.atom<HTMLSpanElement | null>(null)\n    const onClick = (e: JSX.MouseEvent) => {\n        if (e.shiftKey) return\n        editingThis.set(true)\n        e.preventDefault()\n        e.stopPropagation()\n    }\n    editingThis.pipe(L.changes).forEach((editing) => {\n        if (editing) {\n            setTimeout(() => {\n                nameElement.get()!.focus()\n            }, 1)\n        } else {\n            clearSelection()\n        }\n    })\n\n    const endEditing = () => {\n        editingThis.set(false)\n    }\n    const onKeyPress = (e: JSX.KeyboardEvent) => {\n        if (e.key === \"Enter\") {\n            e.preventDefault()\n            commit && commit()\n            editingThis.set(false)\n        } else if (e.key === \"Escape\") {\n            // esc\n            cancel && cancel()\n            editingThis.set(false)\n            nameElement.get()!.textContent = value.get()\n        }\n        e.stopPropagation() // To prevent propagating to higher handlers which, for instance prevent defaults for backspace\n    }\n    const onKey = (e: JSX.KeyboardEvent) => {\n        e.stopPropagation() // To prevent propagating to higher handlers which, for instance prevent defaults for backspace\n    }\n    const onInput = (e: JSX.InputEvent<HTMLSpanElement>) => {\n        value.set(e.currentTarget!.textContent || \"\")\n    }\n\n    const onPaste = (e: JSX.ClipboardEvent<HTMLSpanElement>) => {\n        e.preventDefault()\n        // Paste as plain text, remove formatting.\n        var text = e.clipboardData.getData(\"text/plain\")\n        document.execCommand(\"insertHTML\", false, text)\n    }\n    return (\n        <span onClick={onClick} style={{ cursor: \"pointer\" }} {...rest}>\n            {!!props.showIcon && <span className=\"icon edit\" style={{ marginRight: \"0.3em\", fontSize: \"0.8em\" }} />}\n            <span\n                onBlur={endEditing}\n                contentEditable={editingThis}\n                style={L.view(value, (v) => (v ? {} : { display: \"inline-block\", minWidth: \"1em\", minHeight: \"1em\" }))}\n                ref={nameElement.set}\n                onKeyPress={onKeyPress}\n                onKeyUp={onKeyPress}\n                onKeyDown={onKey}\n                onInput={onInput}\n                onPaste={onPaste}\n            >\n                {props.value}\n            </span>\n        </span>\n    )\n}\n"
  },
  {
    "path": "frontend/src/components/HTMLEditableSpan.tsx",
    "content": "import * as H from \"harmaja\"\nimport * as L from \"lonna\"\nimport { componentScope, h } from \"harmaja\"\nimport { isURL, sanitizeHTML, createLinkHTML } from \"./sanitizeHTML\"\nimport { IS_TOUCHSCREEN } from \"../board/touchScreen\"\n\nexport type EditableSpanProps = {\n    value: L.Atom<string>\n    editingThis: L.Atom<boolean>\n    editable: L.Property<boolean>\n}\n\nconst isFirefox = navigator.userAgent.toLowerCase().indexOf(\"firefox\") > -1\nfunction clearSelection() {\n    if (!isFirefox) {\n        // Don't clear selection on Firefox, because for an unknown reason, the \"selectAll\" functionality below breaks after first clearSelection call.\n        window.getSelection()?.removeAllRanges()\n    }\n}\n\nexport const HTMLEditableSpan = (props: EditableSpanProps) => {\n    let { value, editingThis, editable } = props\n    const editableElement = L.atom<HTMLSpanElement | null>(null)\n    editingThis.pipe(L.changes).forEach((editing) => {\n        if (editing) {\n            setTimeout(() => {\n                editableElement.get()!.focus()\n                if (!IS_TOUCHSCREEN) {\n                    // On iPhone at least the selectAll command prevent the keyboard from showing up\n                    document.execCommand(\"selectAll\", false)\n                }\n            }, 1)\n        } else {\n            clearSelection()\n        }\n    })\n\n    const updateContent = () => {\n        const e = editableElement.get()\n        if (!e) return\n        e.innerHTML = sanitizeHTML(value.get(), true)\n    }\n\n    L.combine(value.pipe(L.applyScope(componentScope())), editableElement, (v, e) => ({ v, e })).forEach(({ v, e }) => {\n        if (!e) return\n        if (e.innerHTML != v) {\n            updateContent()\n        }\n    })\n    editingThis\n        .pipe(\n            L.changes,\n            L.filter((e) => !e),\n            L.applyScope(componentScope()),\n        )\n        .forEach(updateContent)\n\n    const onBlur = (e: JSX.FocusEvent) => {\n        // In Safari, Chrome, some spaces end up being non-breaking spaces in case of pasting content\n        // vs typing it. Didn't find a better way to fix it yet. Replacing innerHTML while editing would\n        // mess up cursor position, so we replace the nbsps onBlur instead.\n        const content = value.get()\n        const fixed = content.replaceAll(\"&nbsp;\", \" \")\n        if (fixed !== content) {\n            value.set(fixed)\n        }\n    }\n    const onKeyPress = (e: JSX.KeyboardEvent) => {\n        e.stopPropagation() // To prevent propagating to higher handlers which, for instance prevent defaults for backspace\n    }\n\n    const onKeyDown = (e: JSX.KeyboardEvent) => {\n        if (e.ctrlKey || e.metaKey) {\n            if (e.key === \"b\") {\n                document.execCommand(\"bold\", false)\n                e.preventDefault()\n            }\n            if (e.key === \"i\") {\n                document.execCommand(\"italic\", false)\n                e.preventDefault()\n            }\n        } else if (e.key === \"Escape\") {\n            // esc\n            editingThis.set(false)\n        }\n        e.stopPropagation() // To prevent propagating to higher handlers which, for instance prevent defaults for backspace\n    }\n    const onKeyUp = onKeyPress\n    const onInput = () => {\n        value.set(editableElement.get()!.innerHTML || \"\")\n    }\n    const onPaste = (e: JSX.ClipboardEvent<HTMLSpanElement>) => {\n        e.preventDefault()\n        // Paste as plain text, remove formatting.\n        var htmlText = e.clipboardData.getData(\"text/html\") || e.clipboardData.getData(\"text/plain\")\n        const sanitized = isURL(htmlText)\n            ? createLinkHTML(htmlText, window.getSelection()!.toString() || undefined)\n            : sanitizeHTML(htmlText)\n        document.execCommand(\"insertHTML\", false, sanitized)\n    }\n\n    const onTouch = (e: JSX.TouchEvent) => {\n        if (!editingThis.get()) {\n            editingThis.set(true)\n        }\n        e.stopPropagation()\n    }\n\n    return (\n        <span style={{ cursor: \"pointer\" }}>\n            <span\n                className=\"editable\"\n                onBlur={onBlur}\n                contentEditable={L.and(editingThis, editable)}\n                style={L.view(value, (v) => (v ? {} : { display: \"inline-block\", minWidth: \"1em\", minHeight: \"1em\" }))}\n                ref={editableElement.set}\n                onKeyPress={onKeyPress}\n                onKeyUp={onKeyUp}\n                onKeyDown={onKeyDown}\n                onInput={onInput}\n                onPaste={onPaste}\n                onTouchStart={onTouch}\n                onTouchEnd={onTouch}\n                onTouchCancel={onTouch}\n                onDoubleClick={(e) => {\n                    e.stopPropagation()\n                    e.preventDefault()\n                    editingThis.set(true)\n                }}\n            ></span>\n        </span>\n    )\n}\n"
  },
  {
    "path": "frontend/src/components/Icons.tsx",
    "content": "import { h } from \"harmaja\"\nimport { Color } from \"../../../common/src/domain\"\nimport * as L from \"lonna\"\nimport { black, disabledColor } from \"../components/UIColors\"\n\nexport const ZoomInIcon = () => (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" height=\"24\" viewBox=\"0 -960 960 960\" width=\"24\">\n        <path\n            d=\"M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Zm-40-60v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z\"\n            fill=\"currentColor\"\n        />\n    </svg>\n)\n\nexport const ZoomOutIcon = () => (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" height=\"24\" viewBox=\"0 -960 960 960\" width=\"24\">\n        <path\n            d=\"M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400ZM280-540v-80h200v80H280Z\"\n            fill=\"currentColor\"\n        />\n    </svg>\n)\n\nexport const ResetZoomIcon = () => (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" height=\"24\" viewBox=\"0 -960 960 960\" width=\"24\">\n        <path\n            d=\"M822-142 592-372q-32 26-71 39t-81 13q-42 0-80-12.5T290-368l58-58q20 12 43 19t49 7q75 0 127.5-52.5T620-580q0-75-52.5-127.5T440-760q-69 0-119.5 46.5T262-598l50-50 56 56-148 148L72-592l56-56 54 52q6-103 80-173.5T440-840q109 0 184.5 75.5T700-580q0 42-13 82t-39 70l230 230-56 56Z\"\n            fill=\"currentColor\"\n        />\n    </svg>\n)\n\nexport const ShapeSquareIcon = (color: Color, fill?: Color) => (\n    <svg viewBox=\"-2 -2 36 36\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <rect\n            x=\"0.5\"\n            y=\"0.5\"\n            width=\"31\"\n            height=\"31\"\n            rx=\"1.5\"\n            stroke={color}\n            stroke-width=\"3\"\n            stroke-linecap=\"round\"\n            fill={fill}\n        />\n    </svg>\n)\n\nexport const ShapeRoundIcon = (color: Color, fill?: Color) => (\n    <svg viewBox=\"-2 -2 36 36\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <rect\n            x=\"0.5\"\n            y=\"0.5\"\n            width=\"31\"\n            height=\"31\"\n            rx=\"15.5\"\n            stroke={color}\n            stroke-width=\"3\"\n            stroke-linecap=\"round\"\n            fill={fill}\n        />\n    </svg>\n)\n\nexport const ShapeRectIcon = (color: Color, fill?: Color) => (\n    <svg viewBox=\"-2 -2 36 36\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <rect\n            x=\"0.5\"\n            y=\"6.5\"\n            width=\"31\"\n            height=\"20\"\n            rx=\"1.5\"\n            stroke={color}\n            stroke-width=\"3\"\n            stroke-linecap=\"round\"\n            fill={fill}\n        />\n    </svg>\n)\n\nexport const ShapeDiamondIcon = (color: Color, fill?: Color) => (\n    <svg viewBox=\"-2 -2 36 36\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <rect\n            fill={fill}\n            x=\"4\"\n            y=\"4\"\n            width=\"23\"\n            height=\"23\"\n            rx=\"1.5\"\n            stroke={color}\n            stroke-width=\"3\"\n            stroke-linecap=\"round\"\n            transform=\"rotate(45 15.5 15.5)\"\n        />\n    </svg>\n)\n\nconst enabledColor = (enabled: L.Property<boolean>) => L.view(enabled, (c) => (c ? black : disabledColor))\n\nexport const IncreaseFontSizeIcon = () => (\n    <svg viewBox=\"0 -3 25 21\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            d=\"M7.11072 0.959999H8.93472L15.8947 18H13.5907L11.5747 13.008H4.42272L2.43072 18H0.126719L7.11072 0.959999ZM11.0947 11.328L8.02272 3.456L4.85472 11.328H11.0947ZM24.9129 8.616V10.344H22.0809V13.416H20.1609V10.344H17.3289V8.616H20.1609V5.544H22.0809V8.616H24.9129Z\"\n            fill=\"currentColor\"\n        />\n    </svg>\n)\n\nexport const DecreaseFontSizeIcon = () => (\n    <svg viewBox=\"0 -4 25 18\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            d=\"M5.01913 0.639999H6.23513L10.8751 12H9.33913L7.99513 8.672H3.22713L1.89913 12H0.363125L5.01913 0.639999ZM7.67513 7.552L5.62713 2.304L3.51513 7.552H7.67513ZM12.0553 8.272V6.992H16.7753V8.272H12.0553Z\"\n            fill=\"currentColor\"\n        />\n    </svg>\n)\n\nexport const UndoIcon = ({ enabled }: { enabled: L.Property<boolean> }) => {\n    const undoColor = enabledColor(enabled)\n    return (\n        <svg width=\"100%\" viewBox=\"0 0 36 30\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n            <path\n                d=\"M4.57075 16.0193C8.69107 8.20216 18.4669 5.25727 26.4057 9.44172C29.6423 11.1477 32.0739 13.7753 33.5397 16.8193\"\n                stroke={undoColor}\n                stroke-width=\"2\"\n                stroke-linecap=\"round\"\n            />\n            <path\n                d=\"M2.43115 11.5003L4.54688 17.1371L10.3929 15.6968\"\n                stroke={undoColor}\n                stroke-width=\"2\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n            />\n        </svg>\n    )\n}\n\nexport const RedoIcon = ({ enabled }: { enabled: L.Property<boolean> }) => {\n    const redoColor = L.view(enabled, (c) => (c ? black : disabledColor))\n    return (\n        <svg width=\"100%\" viewBox=\"0 0 35 29\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n            <path\n                d=\"M30.3954 16.1554C26.5718 8.18892 16.9135 4.87867 8.82307 8.76176C5.52461 10.3449 2.99598 12.8793 1.41683 15.8659\"\n                stroke={redoColor}\n                stroke-width=\"2\"\n                stroke-linecap=\"round\"\n            />\n            <path\n                d=\"M32.7031 11.72L30.377 17.2733L24.5893 15.6143\"\n                stroke={redoColor}\n                stroke-width=\"2\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n            />\n        </svg>\n    )\n}\n\nexport const TextAlignHorizontalLeftIcon = () => (\n    <svg viewBox=\"0 0 24 24\">\n        <path\n            fill=\"currentColor\"\n            d=\"M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z\"\n        ></path>\n    </svg>\n)\n\nexport const TextAlignHorizontalRightIcon = () => (\n    <svg viewBox=\"0 0 24 24\">\n        <path\n            fill=\"currentColor\"\n            d=\"M3 21h18v-2H3v2zm6-4h12v-2H9v2zm-6-4h18v-2H3v2zm6-4h12V7H9v2zM3 3v2h18V3H3z\"\n        ></path>\n    </svg>\n)\n\nexport const TextAlignHorizontalCenterIcon = () => (\n    <svg viewBox=\"0 0 24 24\">\n        <path\n            fill=\"currentColor\"\n            d=\"M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z\"\n        ></path>\n    </svg>\n)\n\nexport const TextAlignVerticalBottomIcon = () => (\n    <svg viewBox=\"0 0 24 24\">\n        <path fill=\"currentColor\" d=\"M16 13h-3V3h-2v10H8l4 4 4-4zM4 19v2h16v-2H4z\"></path>\n    </svg>\n)\n\nexport const TextAlignVerticalMiddleIcon = () => (\n    <svg viewBox=\"0 0 24 24\">\n        <path fill=\"currentColor\" d=\"M8 19h3v4h2v-4h3l-4-4-4 4zm8-14h-3V1h-2v4H8l4 4 4-4zM4 11v2h16v-2H4z\"></path>\n    </svg>\n)\n\nexport const TextAlignVerticalTopIcon = () => (\n    <svg viewBox=\"0 0 24 24\">\n        <path fill=\"currentColor\" d=\"M8 11h3v10h2V11h3l-4-4-4 4zM4 3v2h16V3H4z\"></path>\n    </svg>\n)\n\nexport const AlignHorizontalLeftIcon = () => (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\n        <path fill=\"currentColor\" d=\"M4 22H2V2h2v20zM22 7H6v3h16V7zm-6 7H6v3h10v-3z\" />\n    </svg>\n)\n\nexport const AlignHorizontalRightIcon = () => (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\n        <path fill=\"currentColor\" d=\"M20,2h2v20h-2V2z M2,10h16V7H2V10z M8,17h10v-3H8V17z\" />\n    </svg>\n)\n\nexport const AlignHorizontalCenterIcon = () => (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\n        <polygon\n            fill=\"currentColor\"\n            points=\"11,2 13,2 13,7 21,7 21,10 13,10 13,14 18,14 18,17 13,17 13,22 11,22 11,17 6,17 6,14 11,14 11,10 3,10 3,7 11,7\"\n        />\n    </svg>\n)\n\nexport const AlignVerticalTopIcon = () => (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\n        <path fill=\"currentColor\" d=\"M22 2v2H2V2h20zM7 22h3V6H7v16zm7-6h3V6h-3v10z\" />\n    </svg>\n)\n\nexport const AlignVerticalCenterIcon = () => (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\n        <polygon\n            fill=\"currentColor\"\n            points=\"22,11 17,11 17,6 14,6 14,11 10,11 10,3 7,3 7,11 1.84,11 1.84,13 7,13 7,21 10,21 10,13 14,13 14,18 17,18 17,13 22,13\"\n        />\n    </svg>\n)\n\nexport const AlignVerticalBottomIcon = () => (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\n        <path fill=\"currentColor\" d=\"M22,22H2v-2h20V22z M10,2H7v16h3V2z M17,8h-3v10h3V8z\" />\n    </svg>\n)\n\nexport const HorizontalDistributeIcon = () => (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\n        <path fill=\"currentColor\" d=\"M4 22H2V2h2v20zM22 2h-2v20h2V2zm-8.5 5h-3v10h3V7z\" />\n    </svg>\n)\n\nexport const VerticalDistributeIcon = () => (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\n        <path fill=\"currentColor\" d=\"M22 2v2H2V2h20zM7 10.5v3h10v-3H7zM2 20v2h20v-2H2z\" />\n    </svg>\n)\n\nexport const TileIcon = () => (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\n        <path\n            fill=\"currentColor\"\n            d=\"M3 3v8h8V3H3zm6 6H5V5h4v4zm-6 4v8h8v-8H3zm6 6H5v-4h4v4zm4-16v8h8V3h-8zm6 6h-4V5h4v4zm-6 4v8h8v-8h-8zm6 6h-4v-4h4v4z\"\n            fill-rule=\"evenodd\"\n        />\n    </svg>\n)\n\nexport const BoldIcon = () => (\n    <svg viewBox=\"0 0 24 24\">\n        <path\n            fill=\"currentColor\"\n            d=\"M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42M10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5\"\n        ></path>\n    </svg>\n)\n\nexport const ItalicIcon = () => (\n    <svg viewBox=\"0 0 24 24\">\n        <path fill=\"currentColor\" d=\"M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z\"></path>\n    </svg>\n)\n\nexport const UnderlineIcon = () => (\n    <svg viewBox=\"0 0 24 24\">\n        <path\n            fill=\"currentColor\"\n            d=\"M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6m-7 2v2h14v-2z\"\n        ></path>\n    </svg>\n)\n\nexport const BackIcon = () => (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\n        <path d=\"M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20v-2z\" />\n    </svg>\n)\n\nexport const HistoryIcon = () => (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\">\n        <path d=\"M13 3a9 9 0 0 0-9 9H1l3.89 3.89l.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7s-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54l.72-1.21l-3.5-2.08V8H12z\" />\n    </svg>\n)\n\nexport const UserIcon = () => (\n    <svg viewBox=\"0 0 18 19\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <ellipse\n            cx=\"9.00008\"\n            cy=\"5.44441\"\n            rx=\"4.44441\"\n            ry=\"4.44441\"\n            stroke=\"#566570\"\n            stroke-width=\"1.5\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n        />\n        <path\n            d=\"M17 18C17 13.5817 13.4183 10 9 10C4.58172 10 1 13.5817 1 18\"\n            stroke=\"#566570\"\n            stroke-width=\"1.5\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n        />\n    </svg>\n)\n\nexport const ConnectionLeftArrowIcon = () => (\n    <svg width=\"30\" height=\"12\" viewBox=\"0 0 30 12\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path d=\"M-1.90735e-06 6L10 11.7735V0.226497L-1.90735e-06 6ZM30 5L9 5V7L30 7V5Z\" fill=\"currentColor\" />\n    </svg>\n)\n\nexport const ConnectionCenterLineIcon = () => (\n    <svg width=\"36\" height=\"2\" viewBox=\"0 0 36 2\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <line y1=\"1\" x2=\"36\" y2=\"1\" stroke=\"currentColor\" stroke-width=\"2\" />\n    </svg>\n)\n\nexport const ConnectionRightArrowIcon = () => (\n    <svg width=\"30\" height=\"12\" viewBox=\"0 0 30 12\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path d=\"M30 6L20 0.226497V11.7735L30 6ZM0 7L21 7V5L0 5L0 7Z\" fill=\"currentColor\" />\n    </svg>\n)\n\nexport const ConnectionEndLineIcon = () => (\n    <svg width=\"30\" height=\"2\" viewBox=\"0 0 30 2\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <line y1=\"1\" x2=\"30\" y2=\"1\" stroke=\"currentColor\" stroke-width=\"2\" />\n    </svg>\n)\n\nexport const ConnectionLeftDotIcon = () => (\n    <svg width=\"30\" height=\"8\" viewBox=\"0 0 30 8\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <line x1=\"3\" y1=\"4\" x2=\"30\" y2=\"4\" stroke=\"currentColor\" stroke-width=\"2\" />\n        <circle cx=\"4\" cy=\"4\" r=\"4\" fill=\"currentColor\" />\n    </svg>\n)\n\nexport const ConnectionRightDotIcon = () => (\n    <svg width=\"30\" height=\"8\" viewBox=\"0 0 30 8\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <line x1=\"27\" y1=\"4\" y2=\"4\" stroke=\"currentColor\" stroke-width=\"2\" />\n        <circle cx=\"26\" cy=\"4\" r=\"4\" transform=\"rotate(-180 26 4)\" fill=\"currentColor\" />\n    </svg>\n)\n\nexport const ConnectionCenterCurveIcon = () => (\n    <svg width=\"46\" height=\"26\" viewBox=\"0 0 46 26\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            d=\"M5.07492 8.4044C4.61425 11.5105 17.3143 24.2626 23.054 8.24941C28.7936 -7.76379 41.033 8.09442 41.033 8.09442\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n        />\n    </svg>\n)\n\nexport const LockIcon = () => (\n    <svg viewBox=\"0 0 24 24\">\n        <path d=\"M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z\"></path>\n    </svg>\n)\n\nexport const UnlockIcon = () => (\n    <svg viewBox=\"0 0 24 24\">\n        <path d=\"M12 17c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm6-9h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6h1.9c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm0 12H6V10h12v10z\"></path>\n    </svg>\n)\n\nexport const ConnectionCenterCurveDotIcon = () => (\n    <svg width=\"46\" height=\"26\" viewBox=\"0 0 46 26\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            d=\"M5.07492 8.4044C4.61425 11.5105 17.3143 24.2626 23.054 8.24941C28.7936 -7.76379 41.033 8.09442 41.033 8.09442\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n        />\n        <circle cx=\"23\" cy=\"9\" r=\"4\" fill=\"currentColor\" />\n    </svg>\n)\n\nexport const VisibilityIcon = () => (\n    <svg viewBox=\"0 0 24 24\" fill=\"currentColor\">\n        <path d=\"M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5M12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5m0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3\"></path>\n    </svg>\n)\n\nexport const VisibilityOffIcon = () => (\n    <svg viewBox=\"0 0 24 24\" fill=\"currentColor\">\n        <path d=\"M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7M2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2m4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3z\"></path>\n    </svg>\n)\n"
  },
  {
    "path": "frontend/src/components/ModalContainer.tsx",
    "content": "import { Fragment, h } from \"harmaja\"\nimport * as L from \"lonna\"\n\nexport function ModalContainer({ content }: { content: L.Atom<any> }) {\n    const stopPropagation = (e: JSX.KeyboardEvent) => {\n        e.stopPropagation() // To prevent propagating to higher handlers which, for instance prevent defaults for backspace\n    }\n    return L.view(content, (c) => {\n        if (!c) return null\n        return (\n            <div className=\"modal-container\">\n                <div\n                    onKeyUp={stopPropagation}\n                    onKeyDown={stopPropagation}\n                    onKeyPress={stopPropagation}\n                    className=\"modal-dialog\"\n                >\n                    <div id=\"modal-close\" className=\"modal-close\" onClick={() => content.set(null)}>\n                        <CrossIcon />\n                    </div>\n                    {c}\n                </div>\n            </div>\n        )\n    })\n}\n\nconst CrossIcon = () => {\n    return (\n        <svg\n            width=\"24\"\n            height=\"24\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n            style={\"margin: auto\"}\n        >\n            <path\n                d=\"M5.6297 4.46967C5.3368 4.17678 4.86193 4.17678 4.56904 4.46967C4.27614 4.76256 4.27614 5.23744 4.56904 5.53033L5.6297 4.46967ZM18.569 19.5303C18.8619 19.8232 19.3368 19.8232 19.6297 19.5303C19.9226 19.2374 19.9226 18.7626 19.6297 18.4697L18.569 19.5303ZM19.6297 5.53033C19.9226 5.23744 19.9226 4.76256 19.6297 4.46967C19.3368 4.17678 18.8619 4.17678 18.569 4.46967L19.6297 5.53033ZM4.56904 18.4697C4.27614 18.7626 4.27614 19.2374 4.56904 19.5303C4.86193 19.8232 5.3368 19.8232 5.6297 19.5303L4.56904 18.4697ZM4.56904 5.53033L11.569 12.5303L12.6297 11.4697L5.6297 4.46967L4.56904 5.53033ZM11.569 12.5303L18.569 19.5303L19.6297 18.4697L12.6297 11.4697L11.569 12.5303ZM18.569 4.46967L11.569 11.4697L12.6297 12.5303L19.6297 5.53033L18.569 4.46967ZM11.569 11.4697L4.56904 18.4697L5.6297 19.5303L12.6297 12.5303L11.569 11.4697Z\"\n                fill=\"#151515\"\n            />\n        </svg>\n    )\n}\n"
  },
  {
    "path": "frontend/src/components/UIColors.ts",
    "content": "export const selectedColor = \"#2F80ED\"\nexport const black = \"#00263A\"\nexport const disabledColor = \"lightgrey\"\n"
  },
  {
    "path": "frontend/src/components/browser.ts",
    "content": "export const isFirefox = navigator.userAgent.toLowerCase().indexOf(\"firefox\") > -1\n"
  },
  {
    "path": "frontend/src/components/components.tsx",
    "content": "import * as H from \"harmaja\"\nimport * as L from \"lonna\"\nimport { componentScope, h, HarmajaOutput } from \"harmaja\"\n\nexport const TextInput = (props: { value: L.Atom<string> } & any) => {\n    return (\n        <input\n            {...{\n                type: props.type || \"text\",\n                onInput: (e) => {\n                    props.value.set(e.currentTarget.value)\n                },\n                ...props,\n                value: props.value,\n            }}\n        />\n    )\n}\n\nexport const TextArea = (props: { value: L.Atom<string> } & any) => {\n    return (\n        <textarea\n            {...{\n                onInput: (e) => {\n                    props.value.set(e.currentTarget.value)\n                },\n                ...props,\n                value: props.value,\n            }}\n        />\n    )\n}\n\nexport const Checkbox = (props: { checked: L.Atom<boolean>; children?: H.HarmajaChildOrChildren }) => {\n    return (\n        <div\n            className=\"checkbox\"\n            onClick={(e) => {\n                props.checked.modify((c: boolean) => !c)\n                e.stopPropagation()\n            }}\n        >\n            <span className={props.checked.pipe(L.map((c) => (c ? \"icon checked\" : \"icon\")))} />\n            {props.children}\n        </div>\n    )\n}\n"
  },
  {
    "path": "frontend/src/components/onClickOutside.tsx",
    "content": "import * as L from \"lonna\"\nimport { h, componentScope } from \"harmaja\"\n\nexport function onClickOutside(elem: L.Property<HTMLElement | null>, handler: () => any) {\n    L.fromEvent<JSX.KeyboardEvent>(window, \"mousedown\")\n        .pipe(L.applyScope(componentScope()))\n        .forEach((event) => {\n            if (!elem.get()?.contains(event.target as Node)) {\n                handler()\n            }\n        })\n}\n"
  },
  {
    "path": "frontend/src/components/sanitizeHTML.ts",
    "content": "import sh, { Attributes } from \"sanitize-html\"\n\nconst sanitizeConfig = {\n    allowedTags: [\n        \"b\",\n        \"i\",\n        \"em\",\n        \"strong\",\n        \"a\",\n        \"br\",\n        \"p\",\n        \"ul\",\n        \"li\",\n        \"ol\",\n        \"h1\",\n        \"h2\",\n        \"h3\",\n        \"h4\",\n        \"h5\",\n        \"pre\",\n        \"code\",\n    ],\n    allowedAttributes: {\n        a: [\"href\", \"target\"],\n    },\n    transformTags: {\n        a: (tagName: string, attribs: Attributes) => {\n            return {\n                tagName,\n                attribs: { ...attribs, target: \"_blank\" },\n            }\n        },\n    },\n}\nconst html = `<h1>HELLO</h1> world <b>BOLD</b> <i>ITALIC</i> <script>alert(\"LOL\")</script>`\n\nexport function isURL(str: string) {\n    var urlRegex =\n        \"^(?!mailto:)(?:(?:http|https|ftp)://)(?:\\\\S+(?::\\\\S*)?@)?(?:(?:(?:[1-9]\\\\d?|1\\\\d\\\\d|2[01]\\\\d|22[0-3])(?:\\\\.(?:1?\\\\d{1,2}|2[0-4]\\\\d|25[0-5])){2}(?:\\\\.(?:[0-9]\\\\d?|1\\\\d\\\\d|2[0-4]\\\\d|25[0-4]))|(?:(?:[a-z\\\\u00a1-\\\\uffff0-9]+-?)*[a-z\\\\u00a1-\\\\uffff0-9]+)(?:\\\\.(?:[a-z\\\\u00a1-\\\\uffff0-9]+-?)*[a-z\\\\u00a1-\\\\uffff0-9]+)*(?:\\\\.(?:[a-z\\\\u00a1-\\\\uffff]{2,})))|localhost)(?::\\\\d{2,5})?(?:(/|\\\\?|#)[^\\\\s]*)?$\"\n    var url = new RegExp(urlRegex, \"i\")\n    return str.length < 2083 && url.test(str)\n}\n\nconst MAX_LINK_LENGTH = 30\n\nexport function createLinkHTML(url: string, text?: string) {\n    return createAnchorElement(url, text).outerHTML\n}\n\nfunction createAnchorElement(url: string, text?: string) {\n    if (text === undefined) {\n        text = url.length > MAX_LINK_LENGTH ? url.slice(0, MAX_LINK_LENGTH - 2) + \"...\" : url\n    }\n    const anchorNode = document.createElement(\"a\")\n    anchorNode.textContent = text\n    anchorNode.href = url\n    return anchorNode\n}\n\nfunction linkify(htmlText: string) {\n    helperElem.innerHTML = htmlText\n    for (let e of helperElem.childNodes) {\n        if (e instanceof Text) {\n            const urls = e.textContent!.split(\" \").filter(isURL)\n            if (urls.length) {\n                const url = urls[0]\n                const [before, after] = e.textContent!.split(url).map((t) => new Text(t))\n                const anchorNode = createAnchorElement(url)\n                e.replaceWith(before, anchorNode, after)\n            }\n        }\n    }\n    return helperElem.innerHTML\n}\n\nexport function sanitizeHTML(html: string, shouldLinkify?: boolean) {\n    if (shouldLinkify) {\n        html = linkify(sh(html, sanitizeConfig))\n    }\n    return sh(html, sanitizeConfig)\n}\n\nconst helperElem = document.createElement(\"span\")\n\nexport function toPlainText(html: string) {\n    helperElem.innerHTML = html.replaceAll(\"<br>\", \"\\n\")\n    return helperElem.textContent || \"\"\n}\n"
  },
  {
    "path": "frontend/src/dashboard/DashboardView.tsx",
    "content": "import { Fragment, h, ListView } from \"harmaja\"\nimport { getNavigator, Link } from \"harmaja-router\"\nimport * as L from \"lonna\"\nimport * as R from \"ramda\"\nimport * as uuid from \"uuid\"\nimport {\n    BoardAccessPolicy,\n    BoardStub,\n    CrdtDisabled,\n    CrdtEnabled,\n    EventFromServer,\n    exampleBoard,\n    RecentBoard,\n    ServerConfig,\n} from \"../../../common/src/domain\"\nimport { BOARD_PATH, createBoardAndNavigate, Routes } from \"../board-navigation\"\nimport { localStorageAtom } from \"../board/local-storage-atom\"\nimport { IS_TOUCHSCREEN } from \"../board/touchScreen\"\nimport { BoardAccessPolicyEditor } from \"../components/BoardAccessPolicyEditor\"\nimport { BoardCrdtModeSelector } from \"../components/BoardCrdtModeSelector\"\nimport { TextInput } from \"../components/components\"\nimport { signIn, signOut } from \"../google-auth\"\nimport { Dispatch } from \"../store/board-store\"\nimport { RecentBoards } from \"../store/recent-boards\"\nimport { canLogin, defaultAccessPolicy, UserSessionState } from \"../store/user-session-store\"\n\nexport const DashboardView = ({\n    sessionState,\n    dispatch,\n    recentBoards,\n    eventsFromServer,\n    serverConfig,\n}: {\n    sessionState: L.Property<UserSessionState>\n    recentBoards: RecentBoards\n    dispatch: Dispatch\n    eventsFromServer: L.EventStream<EventFromServer>\n    serverConfig: L.Property<ServerConfig | null>\n}) => {\n    const boardName = L.atom(\"\")\n    return (\n        <div id=\"root\" className=\"dashboard\">\n            <div className=\"content\">\n                <header>\n                    <h1 id=\"app-title\" data-test=\"app-title\">\n                        OurBoard\n                    </h1>\n                    <p>\n                        Free and <a href=\"https://github.com/raimohanska/r-board\">open-source</a>{\" \"}\n                        online&nbsp;whiteboard.\n                    </p>\n                </header>\n                <div className=\"user-info\">\n                    {L.view(sessionState, (user) => {\n                        switch (user.status) {\n                            case \"logged-in\":\n                                return (\n                                    <>\n                                        <span title={user.email}>{user.name}</span>\n                                        <a onClick={signOut}>Sign out</a>\n                                    </>\n                                )\n                            default:\n                                if (canLogin(user)) {\n                                    return <a onClick={signIn}>Sign in</a>\n                                } else {\n                                    return null\n                                }\n                        }\n                    })}\n                </div>\n                <main>\n                    <CreateBoard\n                        {...{ dispatch, sessionState, boardName, recentBoards, eventsFromServer, serverConfig }}\n                    />\n                    <div>\n                        <div className=\"user-content\">\n                            <RecentBoardsView {...{ recentBoards, boardName }} />\n                        </div>\n                    </div>\n                    <Welcome {...{ recentBoards, dispatch, eventsFromServer, sessionState }} />\n                </main>\n            </div>\n            <div className=\"sponsor\">\n                Sponsored by{\" \"}\n                <a href=\"https://www.reaktor.com\">\n                    <img className=\"logo\" src=\"/img/reaktor-logo.jpg\" />\n                </a>\n            </div>\n        </div>\n    )\n}\n\nconst RecentBoardsView = ({ recentBoards, boardName }: { recentBoards: RecentBoards; boardName: L.Atom<string> }) => {\n    const defaultLimit = 25\n    const filter = boardName\n    const filtered = L.view(filter, (f) => !!f)\n    const edit = L.atom(false)\n    const limit = localStorageAtom(\"recentBoards.limit\", defaultLimit)\n\n    const sort = localStorageAtom<\"recent-first\" | \"alphabetical\">(\"recentBoards.sort\", \"recent-first\")\n\n    const matchingBoards = L.view(recentBoards.recentboards, filter, (bs, f) =>\n        bs.filter((b) => b.name.toLowerCase().includes(f.toLowerCase())),\n    )\n    const boardsToShow = L.view(matchingBoards, limit, sort, filter, (bs, l, s, f) =>\n        R.pipe(\n            R.sortWith([R.descend(R.prop(\"opened\"))]),\n            (bs: RecentBoard[]) => bs.slice(0, l),\n            R.sortWith([s === \"alphabetical\" ? R.ascend((b) => b.name.toLowerCase()) : R.descend(R.prop(\"opened\"))]),\n        )(bs),\n    )\n    const moreBoards = L.view(limit, matchingBoards, (l, bs) => bs.length - l)\n    const aLot = 7\n    const lotsOfShownBoards = L.view(matchingBoards, (bs) => bs.length >= aLot)\n    return (\n        <div>\n            {L.view(\n                matchingBoards,\n                (recent) => recent.length === 0,\n                (empty) =>\n                    empty ? null : (\n                        <div\n                            className={L.view(\n                                edit,\n                                filtered,\n                                (e, f) => `recent-boards${e ? \" edit\" : \"\"}${f ? \" filtered\" : \"\"}`,\n                            )}\n                        >\n                            <h2>\n                                {L.view(filter, (f) =>\n                                    f === \"\" ? \"Your recent boards\" : \"Found in your recent boards\",\n                                )}\n                                {IS_TOUCHSCREEN && (\n                                    <a className=\"edit\" onClick={() => edit.modify((e) => !e)}>\n                                        {L.view(edit, (e) => (e ? \"done\" : \"edit\"))}\n                                    </a>\n                                )}\n                            </h2>\n                            <ul>\n                                <ListView\n                                    observable={boardsToShow}\n                                    getKey={(b) => b.id}\n                                    renderItem={(b) => (\n                                        <li>\n                                            <Link<Routes> route={BOARD_PATH} boardId={b.id}>\n                                                {b.name}\n                                            </Link>\n                                            <a className=\"remove\" onClick={() => recentBoards.removeRecentBoard(b)}>\n                                                remove\n                                            </a>\n                                        </li>\n                                    )}\n                                />\n                            </ul>\n                            {\n                                <div className=\"view-options\">\n                                    {L.view(moreBoards, limit, (c, l) =>\n                                        c > 0 ? (\n                                            <a\n                                                href=\"#\"\n                                                onClick={(e) => {\n                                                    e.preventDefault()\n                                                    limit.set(Number.MAX_SAFE_INTEGER)\n                                                }}\n                                            >\n                                                Show {moreBoards} more\n                                            </a>\n                                        ) : l === defaultLimit ? null : (\n                                            <a\n                                                href=\"#\"\n                                                onClick={(e) => {\n                                                    e.preventDefault()\n                                                    limit.set(defaultLimit)\n                                                }}\n                                            >\n                                                Show less\n                                            </a>\n                                        ),\n                                    )}\n                                    {L.view(sort, lotsOfShownBoards, (s, show) =>\n                                        show ? (\n                                            s === \"alphabetical\" ? (\n                                                <a\n                                                    href=\"#\"\n                                                    onClick={(e) => {\n                                                        e.preventDefault()\n                                                        sort.set(\"recent-first\")\n                                                    }}\n                                                >\n                                                    Show recent first\n                                                </a>\n                                            ) : (\n                                                <a\n                                                    href=\"#\"\n                                                    onClick={(e) => {\n                                                        e.preventDefault()\n                                                        sort.set(\"alphabetical\")\n                                                    }}\n                                                >\n                                                    Sort alphabetically\n                                                </a>\n                                            )\n                                        ) : null,\n                                    )}\n                                </div>\n                            }\n                        </div>\n                    ),\n            )}\n        </div>\n    )\n}\n\nconst Welcome = ({\n    recentBoards,\n    dispatch,\n    eventsFromServer,\n    sessionState,\n}: {\n    recentBoards: RecentBoards\n    dispatch: Dispatch\n    eventsFromServer: L.EventStream<EventFromServer>\n    sessionState: L.Property<UserSessionState>\n}) => {\n    const navigator = getNavigator<Routes>()\n    function createTutorial() {\n        createBoardAndNavigate(\n            {\n                id: uuid.v4(),\n                name: \"My personal tutorial board\",\n                templateId: \"tutorial\",\n                accessPolicy: defaultAccessPolicy(sessionState.get(), false),\n            },\n            dispatch,\n            navigator,\n            eventsFromServer,\n        )\n    }\n    const showExampleLink = L.view(recentBoards.recentboards, (boards) => !boards.some((b) => b.id === exampleBoard.id))\n    return (\n        <span>\n            {L.view(\n                recentBoards.recentboards,\n                (recent) => recent.length < 3 && !recent.some((b) => b.name.toLowerCase().includes(\"tutorial\")),\n                (empty) =>\n                    empty ? (\n                        <div>\n                            <h2>Welcome to OurBoard!</h2>\n                            <p>\n                                Let us create a <a onClick={createTutorial}>Tutorial Board</a> just for you, or go ahead\n                                and create a new blank board above.{\" \"}\n                                {L.view(showExampleLink, (s) =>\n                                    s ? (\n                                        <>\n                                            You may also check out the{\" \"}\n                                            <a href={`/b/${exampleBoard.id}`}>Shared test board</a> if you dare!\n                                        </>\n                                    ) : null,\n                                )}\n                            </p>\n                        </div>\n                    ) : null,\n            )}\n        </span>\n    )\n}\n\nconst CreateBoardOptions = ({\n    accessPolicy,\n    sessionState,\n    useCollaborativeEditing,\n    serverConfig,\n}: {\n    accessPolicy: L.Atom<BoardAccessPolicy | undefined>\n    sessionState: L.Property<UserSessionState>\n    useCollaborativeEditing: L.Atom<boolean>\n    serverConfig: L.Property<ServerConfig | null>\n}) => {\n    const optInCollaborative = L.view(\n        sessionState,\n        serverConfig,\n        (s, c) => c?.crdt === \"opt-in\" || (c?.crdt === \"opt-in-authenticated\" && s.status === \"logged-in\"),\n    )\n\n    return (\n        <>\n            {L.view(optInCollaborative, (optIn) => optIn && <BoardCrdtModeSelector {...{ useCollaborativeEditing }} />)}\n            {L.view(sessionState, (s) =>\n                s.status === \"logged-in\" ? (\n                    <BoardAccessPolicyEditor {...{ accessPolicy, user: s }} />\n                ) : (\n                    <small className=\"anonymousBoardDisclaimer\">\n                        Anonymously created boards are accessible to anyone with a link. You may{\" \"}\n                        <a onClick={signIn}>sign in</a> first in order to restrict access to your new board.\n                    </small>\n                ),\n            )}\n        </>\n    )\n}\n\nconst CreateBoard = ({\n    dispatch,\n    sessionState,\n    boardName,\n    recentBoards,\n    eventsFromServer,\n    serverConfig,\n}: {\n    dispatch: Dispatch\n    sessionState: L.Property<UserSessionState>\n    boardName: L.Atom<string>\n    recentBoards: RecentBoards\n    eventsFromServer: L.EventStream<EventFromServer>\n    serverConfig: L.Property<ServerConfig | null>\n}) => {\n    const disabled = L.view(boardName, (n) => !n)\n    const navigator = getNavigator<Routes>()\n    const accessPolicy: L.Atom<BoardAccessPolicy | undefined> = L.atom(defaultAccessPolicy(sessionState.get(), false))\n    sessionState.onChange((s) => {\n        accessPolicy.set(defaultAccessPolicy(s, false))\n    })\n    const hasRecentBoards = L.view(recentBoards.recentboards, (bs) => bs.length > 0)\n    const useCollaborativeEditingAtom = L.atom(false)\n    const useCollaborativeEditing = L.view(serverConfig, useCollaborativeEditingAtom, (c, u) =>\n        c?.crdt === \"true\" ? true : c?.crdt === \"false\" ? false : u,\n    )\n\n    function onSubmit(e: JSX.FormEvent) {\n        e.preventDefault()\n        const newBoard: BoardStub = {\n            name: boardName.get(),\n            id: uuid.v4(),\n            accessPolicy: accessPolicy.get(),\n            crdt: useCollaborativeEditing.get() ? CrdtEnabled : CrdtDisabled,\n        }\n        createBoardAndNavigate(newBoard, dispatch, navigator, eventsFromServer)\n    }\n\n    return (\n        <form onSubmit={onSubmit} className=\"create-board\">\n            <h2>{L.view(hasRecentBoards, (has) => (has ? \"Find or create a board\" : \"Create a board\"))}</h2>\n            <div className=\"input-and-button\">\n                <TextInput value={boardName} autoFocus={!IS_TOUCHSCREEN} placeholder=\"Enter board name\" />\n                <button id=\"create-board-button\" data-test=\"create-board-submit\" type=\"submit\" disabled={disabled}>\n                    Create\n                </button>\n            </div>\n            {L.view(\n                disabled,\n                (d) =>\n                    !d && (\n                        <CreateBoardOptions\n                            {...{\n                                accessPolicy,\n                                sessionState,\n                                useCollaborativeEditing: useCollaborativeEditingAtom,\n                                serverConfig,\n                            }}\n                        />\n                    ),\n            )}\n        </form>\n    )\n}\n"
  },
  {
    "path": "frontend/src/embedding.tsx",
    "content": "const search = new URLSearchParams(location.search)\n\nconst embedded = search.get(\"embedded\") === \"true\"\n\nexport const isEmbedded = () => embedded\n"
  },
  {
    "path": "frontend/src/google-auth.ts",
    "content": "import LocalStore from \"./store/board-local-store\"\n\nexport function signIn() {\n    document.location.assign(\"/login?returnTo=\" + encodeURIComponent(getReturnPath()))\n}\n\nexport async function signOut() {\n    await LocalStore.clearAllPrivateBoards()\n    document.location.assign(\"/logout?returnTo=\" + encodeURIComponent(getReturnPath()))\n}\n\nfunction getReturnPath() {\n    return document.location.pathname + document.location.search\n}\n"
  },
  {
    "path": "frontend/src/index.tsx",
    "content": "import * as H from \"harmaja\"\nimport { h } from \"harmaja\"\nimport _ from \"lodash\"\nimport * as L from \"lonna\"\nimport { EventFromServer, RecentBoardAttributes, ServerConfig } from \"../../common/src/domain\"\nimport \"./app.scss\"\nimport { BoardNavigation } from \"./board-navigation\"\nimport { BoardView } from \"./board/BoardView\"\nimport { IS_TOUCHSCREEN } from \"./board/touchScreen\"\nimport { DashboardView } from \"./dashboard/DashboardView\"\nimport { assetStore } from \"./store/asset-store\"\nimport boardLocalStore from \"./store/board-local-store\"\nimport { BoardState, BoardStore } from \"./store/board-store\"\nimport { CursorsStore } from \"./store/cursors-store\"\nimport { RecentBoards } from \"./store/recent-boards\"\nimport { BrowserSideServerConnection } from \"./store/server-connection\"\nimport { UserSessionStore } from \"./store/user-session-store\"\n\nconst App = () => {\n    const { boardId, page } = BoardNavigation()\n    const connection = BrowserSideServerConnection(boardId)\n    const sessionStore = UserSessionStore(connection, localStorage)\n    const boardStore = BoardStore(boardId, connection, sessionStore.sessionState, boardLocalStore)\n    const cursorsStore = CursorsStore(connection, sessionStore)\n    const recentBoards = RecentBoards(connection, sessionStore)\n    const assets = assetStore(connection, L.view(boardStore.state, \"board\"), boardStore.events)\n    const title = L.view(boardStore.state, (s) => (s.board && s.board.name ? `${s.board.name} - OurBoard` : \"OurBoard\"))\n    title.forEach((t) => (document.querySelector(\"title\")!.textContent = t))\n    const serverConfig = connection.bufferedServerEvents\n        .pipe(\n            L.scan<EventFromServer, ServerConfig | null>(null, (c, e) => (e.action === \"server.config\" ? e : c)),\n        )\n        .applyScope(H.componentScope())\n\n    boardStore.state\n        .pipe(\n            L.changes,\n            L.filter((s: BoardState) => s.status === \"online\" && !!s.board),\n            L.map((s: BoardState) => ({ id: s.board!.id, name: s.board!.name } as RecentBoardAttributes)),\n            L.skipDuplicates(_.isEqual),\n        )\n        .forEach(recentBoards.storeRecentBoard)\n\n    return (\n        <div className={IS_TOUCHSCREEN ? \"touch\" : \"notouch\"}>\n            {L.view(page, (page) => {\n                switch (page.page) {\n                    case \"Board\":\n                        return L.view(\n                            boardStore.state,\n                            (s) => s.board !== undefined,\n                            (hasBoard) =>\n                                hasBoard ? (\n                                    <BoardView\n                                        {...{\n                                            boardId: page.boardId,\n                                            cursors: cursorsStore,\n                                            assets,\n                                            boardStore,\n                                            sessionState: sessionStore.sessionState,\n                                            dispatch: boardStore.dispatch,\n                                        }}\n                                    />\n                                ) : null,\n                        )\n                    case \"NotFound\":\n                    case \"Dashboard\":\n                        return (\n                            <DashboardView\n                                {...{\n                                    dispatch: boardStore.dispatch,\n                                    sessionState: sessionStore.sessionState,\n                                    recentBoards,\n                                    eventsFromServer: connection.bufferedServerEvents,\n                                    serverConfig,\n                                }}\n                            />\n                        )\n                }\n            })}\n        </div>\n    )\n}\n\nH.mount(<App />, document.getElementById(\"root\")!)\n"
  },
  {
    "path": "frontend/src/store/asset-store.ts",
    "content": "import * as L from \"lonna\"\nimport { AppEvent, AssetPutUrlResponse, Board } from \"../../../common/src/domain\"\nimport md5 from \"md5\"\nimport { ServerConnection } from \"./server-connection\"\n\nexport type AssetId = string\nexport type AssetURL = string\n\nexport function assetStore(\n    connection: ServerConnection,\n    board: L.Property<Board | undefined>,\n    events: L.EventStream<AppEvent>,\n) {\n    let dataURLs: Record<AssetId, AssetURL> = {}\n\n    function assetExists(assetId: string): Promise<boolean> {\n        return new Promise((resolve, reject) => {\n            if (\n                Object.values(board.get()!.items).find(\n                    (i) => (i.type === \"image\" || i.type === \"video\") && i.assetId === assetId && i.src,\n                )\n            ) {\n                resolve(true)\n            }\n            const img = new Image()\n            img.onload = () => resolve(true)\n            img.onerror = () => resolve(false)\n            img.src = assetURL(assetId)\n        })\n    }\n\n    function uploadAsset(file: File): Promise<[AssetId, Promise<AssetURL>]> {\n        return new Promise((resolve, reject) => {\n            const reader = new FileReader()\n            reader.readAsDataURL(file)\n            reader.addEventListener(\"loadend\", async (x) => {\n                if (typeof reader.result !== \"string\") {\n                    throw Error(\"Unexpected result type \" + reader.result)\n                }\n                const dataURL = reader.result\n                const assetId = md5(dataURL)\n                dataURLs[assetId] = dataURL\n\n                resolve([\n                    assetId,\n                    (async () => {\n                        const exists = await assetExists(assetId)\n                        const url = assetURL(assetId)\n                        if (exists) {\n                            return url\n                        }\n\n                        console.log(\"PUT REQ\")\n\n                        connection.send({ action: \"asset.put.request\", assetId })\n                        const signedUrl = await getAssetPutResponse(assetId, events)\n\n                        const response = await fetch(signedUrl, {\n                            method: \"PUT\",\n                            body: file,\n                        })\n                        if (response.ok) {\n                            return url\n                        } else {\n                            console.error(\"Asset PUT failed\", response)\n                            throw Error(\"Asset PUT failed with \" + response.status)\n                        }\n                    })(),\n                ])\n            })\n            reader.addEventListener(\"error\", (x) => {\n                console.error(\"File import fail\" + x)\n                reject(x)\n            })\n        })\n    }\n\n    function getExternalAssetAsBytes(url: string) {\n        return fetch(`/assets/external?src=${encodeURI(url)}`)\n    }\n\n    function getAsset(assetId: string, src?: string): AssetURL {\n        if (src) return src\n        const local = dataURLs[assetId]\n        if (local) return local\n        // When src is not set and asset not found locally, it indicates that this image was added by\n        // some other user and image upload is not complete yet. (src will be set on completion)\n        // Currently returned image URL if currently a 404 which results to a temporary \"broken image\"\n        // Shown to other users while upload in progress (this is not bad, but could be better)\n        // TODO: show a progress indicator instead.\n        return `/not-uploaded-yet.png`\n    }\n\n    let assetStorageURL = \"/assets\"\n    function assetURL(assetId: string) {\n        return `${assetStorageURL}/${assetId}`\n    }\n    events.forEach((e) => {\n        if (e.action === \"server.config\") {\n            assetStorageURL = e.assetStorageURL\n        }\n    })\n\n    return {\n        getAsset,\n        uploadAsset,\n        getExternalAssetAsBytes,\n    }\n}\n\nfunction isAssetPut(e: AppEvent): e is AssetPutUrlResponse {\n    return e.action === \"asset.put.response\"\n}\n\nfunction getAssetPutResponse(assetId: string, events: L.EventStream<AppEvent>): Promise<AssetURL> {\n    return new Promise((resolve) => {\n        events\n            .pipe(\n                L.filter(isAssetPut),\n                L.map((e) => e.signedUrl),\n                L.take(1),\n            )\n            .forEach(resolve)\n    })\n}\n\nexport type AssetStore = ReturnType<typeof assetStore>\n"
  },
  {
    "path": "frontend/src/store/board-local-store.ts",
    "content": "import * as localForage from \"localforage\"\nimport { throttle } from \"lodash\"\nimport { Board, BoardHistoryEntry, Id } from \"../../../common/src/domain\"\nimport { migrateBoard, migrateEvent } from \"../../../common/src/migration\"\n\nexport type LocalStorageBoard = {\n    serverShadow: Board // Our view of the board as it is on the server\n    queue: BoardHistoryEntry[] // Local events to send. serverShadow + queue = current board.\n}\n\nconst BOARD_STORAGE_KEY_PREFIX = \"board_\"\n\nlet activeBoardState: LocalStorageBoard | undefined = undefined\n\nasync function getInitialBoardState(boardId: Id) {\n    if (!activeBoardState || activeBoardState.serverShadow.id != boardId) {\n        const localStorageKey = getStorageKey(boardId)\n        activeBoardState = await getStoredState(localStorageKey)\n    }\n    return activeBoardState\n}\n\nasync function getStoredState(localStorageKey: string): Promise<LocalStorageBoard | undefined> {\n    try {\n        const stringState = await localForage.getItem<string>(localStorageKey)\n        const state = JSON.parse(stringState!) as LocalStorageBoard\n        if (!state || !state.serverShadow) {\n            return undefined\n        }\n        return {\n            serverShadow: migrateBoard(state.serverShadow),\n            queue: state.queue.map(migrateEvent),\n        }\n    } catch (e) {\n        console.error(`Fetching local state ${localStorageKey} from IndexedDB failed`, e)\n        await clearStateByKey(localStorageKey)\n    }\n}\n\nfunction getStorageKey(boardId: string) {\n    return BOARD_STORAGE_KEY_PREFIX + boardId\n}\n\nconst maxLocalStoredHistory = 1000 // TODO: limit history to this, using snapshotting\n\nasync function storeBoardState(newState: LocalStorageBoard): Promise<void> {\n    activeBoardState = newState\n    storeThrottled(activeBoardState)\n}\n\nconst storeThrottled = throttle(\n    (newState: LocalStorageBoard) => {\n        try {\n            localForage.setItem(getStorageKey(newState.serverShadow.id), JSON.stringify(newState))\n        } catch (err) {\n            console.error(`Saving board state for ${newState.serverShadow.id} failed`, err)\n        }\n    },\n    1000,\n    { leading: true, trailing: true },\n)\n\nasync function clearBoardState(boardId: Id) {\n    return await clearStateByKey(getStorageKey(boardId))\n}\n\nasync function clearStateByKey(localStorageKey: string) {\n    try {\n        await localForage.removeItem(localStorageKey)\n    } catch (err) {\n        console.error(`Clearing board state for ${localStorageKey} failed`, err)\n    }\n}\n\nasync function clearAllPrivateBoards(): Promise<void> {\n    console.log(\"Clearing all private boards from local storage\")\n    const keys = await localForage.keys()\n    await Promise.all(\n        keys.map(async (k) => {\n            if (!k.startsWith(BOARD_STORAGE_KEY_PREFIX)) return\n            const state = await getStoredState(k)\n            if (state && state.serverShadow.accessPolicy) {\n                console.log(`Clearing local state for private board ${k}`)\n                await clearStateByKey(k)\n            }\n        }),\n    )\n}\n\nexport type BoardLocalStore = {\n    getInitialBoardState: (boardId: Id) => Promise<LocalStorageBoard | undefined>\n    clearBoardState: (boardId: Id) => Promise<void>\n    clearAllPrivateBoards: () => Promise<void>\n    storeBoardState: (newState: LocalStorageBoard) => Promise<void>\n}\n\nexport default {\n    getInitialBoardState,\n    clearBoardState,\n    clearAllPrivateBoards,\n    storeBoardState,\n} as BoardLocalStore\n"
  },
  {
    "path": "frontend/src/store/board-store.test.ts",
    "content": "import { describe, expect, it } from \"vitest\"\n\nimport { BoardStore } from \"./board-store\"\nimport * as L from \"lonna\"\nimport {\n    EventFromServer,\n    UIEvent,\n    EventWrapper,\n    Id,\n    newBoard,\n    newNote,\n    Board,\n    BoardHistoryEntry,\n    getBoardAttributes,\n    JoinBoard,\n} from \"../../../common/src/domain\"\nimport { UserSessionState } from \"./user-session-store\"\nimport { LocalStorageBoard } from \"./board-local-store\"\nimport { sleep } from \"../../../common/src/sleep\"\nimport { mkBootStrapEvent } from \"../../../common/src/migration\"\nimport * as WebSocket from \"ws\"\n\nconst otherUserEventAttributes = { user: { userType: \"unidentified\", nickname: \"joe\" }, timestamp: \"0\" } as const\nconst localUser = { userType: \"unidentified\", nickname: \"\" } as const\n\nconst board0 = newBoard(\"testboard\")\nconst item1 = newNote(\"hello1\")\nconst addItem1: BoardHistoryEntry = {\n    action: \"item.add\",\n    boardId: board0.id,\n    items: [item1],\n    connections: [],\n    serial: 1,\n    ...otherUserEventAttributes,\n}\nconst board1 = { ...board0, serial: 1, items: { [item1.id]: item1 } }\nconst item1_1 = { ...item1, text: \"hello1.1\" }\nconst board1_1 = { ...board1, serial: 2, items: { ...board1.items, [item1.id]: item1_1 } }\nconst item2 = newNote(\"hello2\")\nconst board2 = { ...board1, serial: 1, items: { ...board1.items, [item2.id]: item2 } }\nconst item2_1 = { ...item2, text: \"hello2.1\" }\nconst board2_1 = { ...board2, serial: 2, items: { ...board2.items, [item1.id]: item1_1 } }\nconst board2_2 = { ...board2, serial: 2, items: { ...board2.items, [item1.id]: item1_1, [item2.id]: item2_1 } }\n\ndescribe(\"Board Store\", () => {\n    it(\"Applies event from server\", async () => {\n        const { store, serverEvents } = await initBoardStore({ serverSideBoard: board0 })\n        expect(store.state.get().board).toEqual(board0)\n\n        serverEvents.push(addItem1)\n\n        expect(store.state.get().board).toEqual(board1)\n        expect(store.state.get().serverShadow).toEqual(store.state.get().board)\n    })\n\n    it(\"Applies local event\", async () => {\n        const { store, serverEvents } = await initBoardStore({ serverSideBoard: board0 })\n        expect(store.state.get().board).toEqual(board0)\n\n        // 1. Event applied locally, serverShadow and serial unchanged\n        store.dispatch({ action: \"item.add\", boardId: board0.id, items: [item1], connections: [] })\n        expect(store.state.get().board).toEqual({ ...board0, serial: 0, items: { [item1.id]: item1 } })\n        expect(store.state.get().serverShadow).toEqual(board0)\n\n        // 2. Ack from server, update serverShadow and serial\n        serverEvents.push({ action: \"ack\", ackId: \"\", serials: { [board0.id]: 1 } })\n        expect(store.state.get().board).toEqual(board1)\n        expect(store.state.get().serverShadow).toEqual(store.state.get().board)\n    })\n\n    it(\"Rebases local event when remote event arrives before ack\", async () => {\n        const { store, serverEvents } = await initBoardStore({ serverSideBoard: board1 })\n        expect(store.state.get().board).toEqual(board1)\n        // 1. Event applied locally, serverShadow and serial unchanged\n        store.dispatch({ action: \"item.add\", boardId: board0.id, items: [item2], connections: [] })\n        expect(store.state.get().board).toEqual(board2)\n        expect(store.state.get().serverShadow).toEqual(board1)\n        expect(store.state.get().queue.length).toEqual(0)\n        expect(store.state.get().sent.length).toEqual(1)\n\n        // 2. Second local event gets queued, not sent to server before ack\n        store.dispatch({ action: \"item.update\", boardId: board0.id, items: [item2_1] })\n        expect(store.state.get().board).toEqual({ ...board2, items: { ...board2.items, [item2.id]: item2_1 } })\n        expect(store.state.get().serverShadow).toEqual(board1)\n        expect(store.state.get().queue.length).toEqual(1)\n        expect(store.state.get().sent.length).toEqual(1)\n\n        // 3. Event from server applied to serverShadow and locally, local event still unapplied to serverShadow\n        serverEvents.push({\n            action: \"item.update\",\n            boardId: board0.id,\n            items: [{ ...item1, text: \"hello1.1\" }],\n            serial: 2,\n            ...otherUserEventAttributes,\n        })\n        expect(store.state.get().board).toEqual(board2_2)\n        expect(store.state.get().serverShadow).toEqual(board1_1)\n\n        // 4. Ack from server, update serverShadow and serial for the first local event\n        serverEvents.push({ action: \"ack\", ackId: \"\", serials: { [board0.id]: 3 } })\n        expect(store.state.get().board).toEqual({ ...board2_2, serial: 3 })\n        expect(store.state.get().serverShadow).toEqual({ ...board2_1, serial: 3 })\n        expect(store.state.get().queue.length).toEqual(0)\n        expect(store.state.get().sent.length).toEqual(1)\n\n        // 5. Ack from server, update for the second local event\n        serverEvents.push({ action: \"ack\", ackId: \"\", serials: { [board0.id]: 4 } })\n        expect(store.state.get().board).toEqual({ ...board2_2, serial: 4 })\n        expect(store.state.get().serverShadow).toEqual(store.state.get().board)\n        expect(store.state.get().queue.length).toEqual(0)\n        expect(store.state.get().sent.length).toEqual(0)\n    })\n})\n\ndescribe(\"With stored local state\", () => {\n    it(\"Without server-side changes\", async () => {\n        const { store } = await initBoardStore({\n            serverSideBoard: board1,\n            serverSideHistory: [mkBootStrapEvent(board1.id, board1, board1.serial)],\n            locallyStoredBoard: {\n                serverShadow: board1,\n                queue: [],\n            },\n        })\n\n        expect(store.state.get().board).toEqual(board1)\n        expect(store.state.get().serverShadow).toEqual(store.state.get().board)\n    })\n\n    it(\"With server-side changes\", async () => {\n        const { store } = await initBoardStore({\n            serverSideBoard: { ...board2, serial: 2 },\n            serverSideHistory: [\n                mkBootStrapEvent(board1.id, board1, board1.serial),\n                {\n                    action: \"item.add\",\n                    boardId: board0.id,\n                    items: [item2],\n                    connections: [],\n                    serial: 2,\n                    ...otherUserEventAttributes,\n                },\n            ],\n            locallyStoredBoard: {\n                serverShadow: board1,\n                queue: [],\n            },\n        })\n\n        expect(store.state.get().board).toEqual({ ...board2, serial: 2 })\n        expect(store.state.get().serverShadow).toEqual(store.state.get().board)\n    })\n\n    it(\"With queued local changes\", async () => {\n        const { store } = await initBoardStore({\n            serverSideBoard: board1,\n            serverSideHistory: [],\n            locallyStoredBoard: {\n                serverShadow: board1,\n                queue: [\n                    { action: \"item.update\", boardId: board0.id, items: [item1_1], timestamp: \"0\", user: localUser },\n                ],\n            },\n        })\n\n        expect(store.state.get().board).toEqual({ ...board1, items: { [item1.id]: item1_1 } })\n    })\n\n    it(\"With queued local changes and server-side changes\", async () => {\n        const { store } = await initBoardStore({\n            serverSideBoard: { ...board2, serial: 2 },\n            serverSideHistory: [\n                mkBootStrapEvent(board1.id, board1, board1.serial),\n                {\n                    action: \"item.add\",\n                    boardId: board0.id,\n                    items: [item2],\n                    connections: [],\n                    serial: 2,\n                    ...otherUserEventAttributes,\n                },\n            ],\n            locallyStoredBoard: {\n                serverShadow: board1,\n                queue: [\n                    { action: \"item.update\", boardId: board0.id, items: [item1_1], timestamp: \"0\", user: localUser },\n                ],\n            },\n        })\n\n        // The server-side changes should be applied first, then the queued local changes\n        expect(store.state.get().board).toEqual({\n            ...board2,\n            serial: 2,\n            items: { ...board2.items, [item1.id]: item1_1 },\n        })\n    })\n\n    it(\"Going offline and back online\", async () => {\n        const { store, serverEvents, connected, sentEvents, replyToJoinRequest } = await initBoardStore({\n            serverSideBoard: board0,\n        })\n        expect(store.state.get().board).toEqual(board0)\n        const sentBeforeOffline = sentEvents.slice()\n\n        // Create a local change (add item2)\n        const localAddEvent: UIEvent = { action: \"item.add\", boardId: board0.id, items: [item2], connections: [] }\n        const expectedSentEvent = {\n            action: \"item.add\",\n            boardId: board0.id,\n            items: [item2],\n            connections: [],\n            timestamp: expect.any(String),\n            user: { userType: \"unidentified\", nickname: \"<unknown>\" },\n        } as const\n        store.dispatch(localAddEvent)\n        expect(store.state.get().sent).toEqual([expectedSentEvent])\n        const expectedAckBundle = {\n            // Events are sent in bundles, which include all events in the \"sent\" buffer when sending.\n            ackId: \"1\",\n            events: [expectedSentEvent],\n        }\n        expect(sentEvents).toEqual([...sentBeforeOffline, expectedAckBundle])\n\n        // Go offline. Sent but not acknowledged events are discarded.\n        connected.set(false)\n        expect(store.state.get().status).toEqual(\"offline\")\n        expect(store.state.get().sent).toEqual([])\n        expect(store.state.get().board).toEqual(board0) // Also local state is reset to reflect the discarded event\n\n        // Create local event offline (add item2)\n        store.dispatch(localAddEvent)\n        expect(store.state.get().board).toEqual({ ...board0, serial: 0, items: { [item2.id]: item2 } })\n        expect(store.state.get().serverShadow).toEqual(board0)\n\n        // Go back online\n        connected.set(true)\n        expect(store.state.get().status).toEqual(\"joining\")\n        const expectedRejoinRequest = {\n            action: \"board.join\",\n            boardId: board0.id,\n            initAtSerial: 0,\n        } as const\n        expect(sentEvents).toEqual([...sentBeforeOffline, expectedAckBundle, expectedRejoinRequest])\n\n        // Meanwhile, item1 was added on the server. Now server responds to the rejoin request\n        replyToJoinRequest(expectedRejoinRequest, board1, [addItem1])\n        expect(store.state.get().board).toEqual(board2) // Client has merged changes from server\n        expect(store.state.get().sent).toEqual([expectedSentEvent])\n        expect(store.state.get().serverShadow).toEqual(board1)\n        expect(sentEvents).toEqual([...sentBeforeOffline, expectedAckBundle, expectedRejoinRequest, expectedAckBundle])\n\n        // Ack from server, update serverShadow and serial\n        serverEvents.push({ action: \"ack\", ackId: \"\", serials: { [board0.id]: 2 } })\n        expect(store.state.get().board).toEqual({ ...board2, serial: 2 })\n        expect(store.state.get().serverShadow).toEqual({ ...board2, serial: 2 })\n    })\n})\n\n// TODO: test going offline before getting board.init (case server redirects to other URL)\n// TODO: test effects of server event buffering (bufferedServerEvents)\n// TODO: test undo, redo buffers\n\nasync function waitForBackgroundJobs() {\n    await sleep(10)\n}\n\nasync function initBoardStore({\n    serverSideBoard,\n    locallyStoredBoard,\n    serverSideHistory,\n}: {\n    serverSideBoard: Board\n    locallyStoredBoard?: LocalStorageBoard\n    serverSideHistory?: BoardHistoryEntry[]\n}) {\n    const serverEvents = L.bus<EventFromServer>()\n    const boardId = L.constant(serverSideBoard.id)\n    const connected = L.atom(true)\n    let sentEvents: (UIEvent | EventWrapper)[] = []\n    const send = (x: UIEvent | EventWrapper) => sentEvents.push(x)\n    const sessionInfo = L.atom<UserSessionState>({\n        status: \"anonymous\",\n        sessionId: \"\",\n        nickname: \"\",\n        nicknameSetByUser: false,\n        loginSupported: true,\n    })\n\n    const connection = {\n        connected,\n        send,\n        bufferedServerEvents: serverEvents,\n        sentUIEvents: null as any, // not used by BoardStore,\n        newSocket: function () {},\n    }\n\n    const localStore = {\n        getInitialBoardState: async (boardId: Id): Promise<LocalStorageBoard | undefined> => {\n            return locallyStoredBoard\n        },\n        clearBoardState: async (boardId: Id) => {},\n        clearAllPrivateBoards: async () => {},\n        storeBoardState: async (newState: LocalStorageBoard) => {},\n    }\n\n    const store = BoardStore(boardId, connection, sessionInfo, localStore, WebSocket as any)\n\n    expect(store.state.get().board).toEqual(undefined)\n    expect(store.state.get().queue).toEqual([])\n    expect(store.state.get().sent).toEqual([])\n    expect(store.state.get().serverShadow).toEqual(undefined)\n    expect(store.state.get().status).toEqual(\"none\")\n\n    expect(sentEvents).toEqual([])\n\n    await waitForBackgroundJobs()\n\n    sessionInfo.set({\n        status: \"anonymous\",\n        nickname: localUser.nickname,\n        nicknameSetByUser: false,\n        sessionId: \"\",\n        loginSupported: false,\n    })\n\n    await waitForBackgroundJobs()\n\n    const expectedJoinRequest = {\n        action: \"board.join\",\n        boardId: board0.id,\n        initAtSerial: locallyStoredBoard?.serverShadow?.serial,\n    } as const\n    expect(sentEvents).toEqual([expectedJoinRequest])\n\n    function replyToJoinRequest(\n        joinRequest: JoinBoard,\n        serverSideBoard: Board,\n        serverSideHistory: BoardHistoryEntry[],\n    ) {\n        const initAtSerial = joinRequest.initAtSerial\n        if (initAtSerial != undefined) {\n            serverEvents.push({\n                action: \"board.init.diff\",\n                first: true,\n                last: true,\n                recentEvents: serverSideHistory!.filter((e) => e.serial! > initAtSerial),\n                boardAttributes: getBoardAttributes(serverSideBoard),\n                initAtSerial,\n                accessLevel: \"read-write\",\n            })\n        } else {\n            serverEvents.push({ action: \"board.init\", board: serverSideBoard, accessLevel: \"read-write\" })\n        }\n    }\n    replyToJoinRequest(expectedJoinRequest, serverSideBoard, serverSideHistory || [])\n\n    await waitForBackgroundJobs()\n\n    expect(store.state.get().serverShadow).toEqual(serverSideBoard)\n\n    return { store, serverEvents, connected, sentEvents, replyToJoinRequest } as const\n}\n"
  },
  {
    "path": "frontend/src/store/board-store.ts",
    "content": "import _ from \"lodash\"\nimport * as L from \"lonna\"\nimport { globalScope } from \"lonna\"\nimport { addOrReplaceEvent, foldActions } from \"../../../common/src/action-folding\"\nimport { assertNotNull } from \"../../../common/src/assertNotNull\"\nimport { boardReducer } from \"../../../common/src/board-reducer\"\nimport {\n    AccessLevel,\n    AckAddBoard,\n    Board,\n    BoardHistoryEntry,\n    BoardStateSyncEvent,\n    ClientToServerRequest,\n    CursorMove,\n    EventUserInfo,\n    Id,\n    Item,\n    ItemLocks,\n    JoinBoard,\n    LocalUIEvent,\n    LoginResponse,\n    PersistableBoardItemEvent,\n    ServerConfig,\n    SessionUserInfo,\n    TransientBoardItemEvent,\n    UIEvent,\n    UserSessionInfo,\n    canWrite,\n    defaultBoardSize,\n    isBoardHistoryEntry,\n    isCursorMove,\n    isLocalUIEvent,\n    isPersistableBoardItemEvent,\n    isTextItem,\n    newISOTimeStamp,\n} from \"../../../common/src/domain\"\nimport { BoardLocalStore, LocalStorageBoard } from \"./board-local-store\"\nimport { ServerConnection, getWebSocketRootUrl } from \"./server-connection\"\nimport { UserSessionState, isLoginInProgress } from \"./user-session-store\"\nimport { CRDTStore, WebSocketPolyfill } from \"./crdt-store\"\nimport { serve } from \"esbuild\"\nexport type Dispatch = (e: UIEvent) => void\nexport type BoardStore = ReturnType<typeof BoardStore>\nexport type BoardAccessStatus =\n    | \"none\"\n    | \"loading\"\n    | \"joining\"\n    | \"offline\"\n    | \"online\"\n    | \"denied-temporarily\"\n    | \"denied-permanently\"\n    | \"login-required\"\n    | \"not-found\"\nexport type BoardState = {\n    status: BoardAccessStatus\n    accessLevel: AccessLevel\n    board: Board | undefined // Current board shown on the client\n    serverShadow: Board | undefined // Our view of the board as it is on the server\n    queue: (BoardHistoryEntry | CursorMove)[] // Local events to send. serverShadow + sent + queue = current board\n    sent: (BoardHistoryEntry | CursorMove)[] // Events sent to server, waiting for ack\n    locks: ItemLocks\n    users: UserSessionInfo[]\n}\n\nfunction emptyBoard(boardId: Id) {\n    return { id: boardId, name: \"\", ...defaultBoardSize, items: {}, connections: [], serial: 0 }\n}\n\nexport function BoardStore(\n    boardId: L.Property<Id | undefined>,\n    connection: ServerConnection,\n    sessionInfo: L.Property<UserSessionState>,\n    localStore: BoardLocalStore,\n    WebSocketPolyfill: WebSocketPolyfill = WebSocket,\n) {\n    type BoardStoreEvent =\n        | BoardHistoryEntry\n        | TransientBoardItemEvent\n        | BoardStateSyncEvent\n        | LocalUIEvent\n        | ClientToServerRequest\n        | LoginResponse\n        | AckAddBoard\n        | ServerConfig\n\n    function tagWithUserFromState(e: PersistableBoardItemEvent): BoardHistoryEntry {\n        const user: EventUserInfo = sessionState2UserInfo(sessionInfo.get())\n        return {\n            ...e,\n            user,\n            timestamp: newISOTimeStamp(),\n        }\n    }\n\n    interface CommandStack {\n        add(event: PersistableBoardItemEvent): void\n    }\n\n    const ACK_ID = \"1\"\n\n    type BoardStateFromLocalStorage = {\n        boardId: Id\n        storedInitialState: LocalStorageBoard | undefined\n    }\n\n    function flushIfPossible(state: BoardState): BoardState {\n        // Only flush when board is ready and we are not waiting for ack.\n        if (state.status === \"online\" && state.sent.length === 0 && state.queue.length > 0) {\n            //console.log(`Send ${state.queue.map(i => i.action)}, await ack for ${state.queue.length}`)\n            connection.send({ events: state.queue, ackId: ACK_ID })\n            return { ...state, queue: [], sent: state.queue }\n        }\n        return state\n    }\n\n    function resetForBoard(boardId: Id) {\n        console.log(\"Reseting and joining board\")\n        console.log(\"Sending board.join without initAtSerial\")\n        localStore.clearBoardState(boardId).then(() =>\n            connection.send({\n                action: \"board.join\",\n                boardId,\n            }),\n        )\n    }\n\n    function CommandStack() {\n        let stack = L.atom<PersistableBoardItemEvent[]>([])\n        let canPop = L.view(stack, (s) => s.length > 0)\n        return {\n            add(event: PersistableBoardItemEvent) {\n                stack.modify((s) => addToStack(event, s))\n            },\n            pop(state: BoardState, otherStack: CommandStack): BoardState {\n                const undoOperation = _.last(stack.get())\n                if (!undoOperation) return state\n                stack.modify((s) => s.slice(0, s.length - 1))\n                const operationAsHistoryEntry = tagWithUserFromState(undoOperation)\n                const [board, reverse] = boardReducer(state.board!, operationAsHistoryEntry)\n                if (reverse) otherStack.add(reverse())\n                return flushIfPossible({\n                    ...state,\n                    board,\n                    queue: addOrReplaceEvent(operationAsHistoryEntry, state.queue),\n                })\n            },\n            clear() {\n                stack.set([])\n            },\n            canPop,\n        }\n\n        function addToStack(\n            event: PersistableBoardItemEvent,\n            b: PersistableBoardItemEvent[],\n        ): PersistableBoardItemEvent[] {\n            const latest = b[b.length - 1]\n            if (latest) {\n                const folded = foldActions(event, latest)\n                if (folded) {\n                    // Replace top of stack with folded\n                    return [...b.slice(0, b.length - 1), folded] as any // TODO: can we get better types?\n                }\n            }\n\n            return b.concat(event)\n        }\n    }\n    let undoStack = CommandStack()\n    let redoStack = CommandStack()\n\n    let initialServerSyncEventBuffer: BoardHistoryEntry[] = []\n\n    const eventsReducer = (state: BoardState, event: BoardStoreEvent): BoardState => {\n        if (state.status === \"online\") {\n            // Process these events only when online\n            if (event.action === \"cursor.move\") {\n                return flushIfPossible({ ...state, queue: addOrReplaceEvent(event, state.queue) })\n            }\n        }\n        if (state.status === \"online\" || state.status === \"offline\") {\n            // Process these events only when online or offline\n            if (event.action === \"ui.undo\") {\n                return undoStack.pop(state, redoStack)\n            } else if (event.action === \"ui.redo\") {\n                return redoStack.pop(state, undoStack)\n            } else if (isPersistableBoardItemEvent(event)) {\n                try {\n                    if (event.serial == undefined) {\n                        // No serial == is local event.\n                        if (!canWrite(state.accessLevel)) return state\n                        redoStack.clear()\n                        const [board, reverse] = boardReducer(state.board!, event)\n                        if (reverse) undoStack.add(reverse())\n                        return flushIfPossible({ ...state, board, queue: addOrReplaceEvent(event, state.queue) })\n                    } else {\n                        // Remote event\n                        if (state.status !== \"online\") {\n                            // Skip while not online. For instance, when recently reconnected, we may receive events from others while still\n                            // Waiting for our final board.init.diff sync event. It would be disastrous to process these events before fully synced.\n                            // The server will re-send any missed events after sync, after which we can start processing normally.\n                            return state\n                        }\n                        const [newServerShadow] = boardReducer(state.serverShadow!, event, { strictOnSerials: true })\n                        // Rebase local events on top of new server shadow. If this fails, there's a catch below.\n                        const localEvents = [...state.sent, ...state.queue]\n                        const board = localEvents\n                            .filter(isBoardHistoryEntry)\n                            .reduce((b, e) => boardReducer(b, e)[0], newServerShadow)\n                        //console.log(`Processed remote board event and rebased ${localEvents.length} local events on top`, event)\n                        return { ...state, serverShadow: newServerShadow, board }\n                    }\n                } catch (e) {\n                    console.error(\"Error applying event. Fetching as new board...\", e)\n                    resetForBoard(event.boardId)\n                    return {\n                        ...state,\n                        status: \"joining\",\n                        sent: [],\n                        queue: [],\n                    }\n                }\n            } else if (event.action === \"board.joined\") {\n                return { ...state, users: state.users.concat(event) }\n            } else if (event.action === \"board.left\") {\n                return { ...state, users: state.users.filter((u) => u.sessionId !== event.sessionId) }\n            } else if (event.action === \"board.locks\") {\n                return { ...state, locks: event.locks }\n            } else if (event.action === \"ack\") {\n                const newSerial = state.board ? event.serials[state.board.id] : undefined\n                if (!newSerial) {\n                    //console.log(\"Got ack\")\n                    return flushIfPossible({ ...state, sent: [] })\n                } else {\n                    // Our sent events now acknowledged and will be incorporated into serverShadow\n                    const newServerEvents = state.sent.filter(isBoardHistoryEntry)\n                    const newServerShadow =\n                        state.queue.length > 0\n                            ? newServerEvents.reduce((b, e) => boardReducer(b, e)[0], state.serverShadow!)\n                            : state.board! // No queued events -> no need to calculate\n\n                    //console.log(`Got ack. Joined ${state.sent.length} local events to server history and shadow (${state.serverShadow?.serial}->${newSerial}), shortcutted=${newServerShadow===state.board}`)\n\n                    return flushIfPossible({\n                        ...state,\n                        board: { ...state.board!, serial: newSerial },\n                        serverShadow: { ...newServerShadow, serial: newSerial },\n                        sent: [],\n                    })\n                }\n            } else if (event.action === \"board.action.apply.failed\") {\n                console.error(\"Failed to apply previous action. Resetting to server-side state...\")\n                if (state.board) {\n                    resetForBoard(state.board.id)\n                    return {\n                        ...state,\n                        status: \"joining\",\n                    }\n                }\n                return state\n            }\n        }\n        // Process these events in both ready and not-ready states\n        if (event.action === \"ui.board.logged.out\") {\n            return { ...state, board: emptyBoard(event.boardId), status: \"denied-permanently\" }\n        }\n        if (event.action === \"board.join.denied\") {\n            state = { ...state, board: emptyBoard(event.boardId) }\n            const loginStatus = sessionInfo.get().status\n            if (state.status !== \"loading\" && state.status != \"joining\") {\n                console.error(`Got board.join.denied while in status ${state.status}`)\n            }\n            if (loginStatus === \"logging-in-server\") {\n                console.log(`Access denied to board: login in progress`)\n                return { ...state, status: \"denied-temporarily\" }\n            } else if (event.reason === \"not found\") {\n                console.log(`Access denied to board: ${event.reason}`)\n                return { ...state, status: \"not-found\" }\n            } else if (loginStatus === \"anonymous\" || loginStatus === \"logged-out\" || loginStatus === \"login-failed\") {\n                console.log(`Access denied to board: login required`)\n                return { ...state, status: \"login-required\" }\n            } else if (event.reason === \"unauthorized\") {\n                console.warn(`Got \"unauthorized\" while logged in, likely login in progress...`)\n                return state\n            } else if (event.reason === \"forbidden\") {\n                console.log(`Access denied to board: ${event.reason}`)\n                return { ...state, status: \"denied-permanently\" }\n            } else {\n                console.error(`Unexpected board access denial: ${state.status}/${loginStatus}/${event.reason}`)\n                return state\n            }\n        }\n        if (event.action === \"ui.board.setLocal\") {\n            // Locally dispatched, see below\n            console.log(\"ui.board.join.request -> reset\")\n            undoStack.clear()\n            redoStack.clear()\n            if (!event.boardId) {\n                console.log(\"Board id reseted. Reseting board state.\")\n                return initialState\n            }\n            const storedInitialState = event.storedInitialState\n            if (storedInitialState && storedInitialState.serverShadow.id === event.boardId) {\n                console.log(`Starting offline with local board state, serial=${storedInitialState.serverShadow.serial}`)\n                const board = storedInitialState.queue.reduce(\n                    (b, e) => boardReducer(b, e)[0],\n                    storedInitialState.serverShadow,\n                )\n\n                return {\n                    ...initialState,\n                    status: \"offline\",\n                    queue: storedInitialState.queue,\n                    sent: [],\n                    serverShadow: storedInitialState.serverShadow,\n                    board,\n                }\n            }\n            console.log(`Starting with empty board state`)\n            return {\n                ...initialState,\n                status: \"loading\",\n                queue: [],\n                sent: [],\n                board: emptyBoard(event.boardId),\n            }\n        } else if (event.action === \"ui.online\") {\n            if (state.status !== \"online\" && state.status !== \"joining\") {\n                if (!state.board) throw Error(\"Trying to go online without a board\")\n                const initAtSerial = state.serverShadow?.serial\n\n                const joinRequest: JoinBoard = {\n                    action: \"board.join\",\n                    boardId: state.board.id,\n                    initAtSerial,\n                }\n                console.log(`Sending board.join at serial ${joinRequest.initAtSerial}`)\n                connection.send(joinRequest)\n                return {\n                    ...state,\n                    status: \"joining\",\n                }\n            } else {\n                console.warn(\"Not joining in state\", state.status)\n            }\n            return state\n        } else if (event.action === \"userinfo.set\") {\n            const users = state.users.map((u) => (u.sessionId === event.sessionId ? event : u))\n            return { ...state, users }\n        } else if (event.action === \"board.init\") {\n            if (event.board.id !== state.board?.id) {\n                console.warn(`Got board.init for non-matching board ${event.board.id} != ${boardId.get()}`)\n                return state\n            }\n            console.log(`Going to online mode. Init as new board at serial ${event.board.serial}`)\n            const newServerShadow = event.board\n            // Local board = server shadow + local queue\n            const queue = state.queue.filter(isBoardHistoryEntry)\n            const board = queue.reduce((b, e) => boardReducer(b, e)[0], newServerShadow)\n            return {\n                ...state,\n                status: \"online\",\n                board,\n                accessLevel: event.accessLevel,\n                serverShadow: newServerShadow,\n                sent: [],\n            }\n        } else if (event.action === \"board.init.diff\") {\n            if (event.boardAttributes.id !== state.board?.id) {\n                console.warn(`Got board.init for non-matching board ${event.boardAttributes.id} != ${state.board?.id}`)\n                return state\n            }\n            if (event.first) {\n                // Ensure local buffer empty on first chunk even if an earlier init was aborted.\n                initialServerSyncEventBuffer = []\n            }\n            const boardId = event.boardAttributes.id\n            try {\n                if (!state.serverShadow) throw Error(`Trying to init at ${event.initAtSerial} without serverShadow`)\n                if (state.serverShadow.id !== event.boardAttributes.id)\n                    throw Error(`Trying to init board with nonmatching id`)\n                const events = event.recentEvents\n                initialServerSyncEventBuffer.push(...events)\n                if (!event.last) {\n                    console.log(\n                        `Got board.init.diff chunk of ${events.length} events (${events[0].serial}..${\n                            events[events.length - 1].serial\n                        }), waiting for more...`,\n                    )\n                    return state\n                }\n\n                if (state.serverShadow.serial != event.initAtSerial)\n                    throw Error(\n                        `Trying to init at ${event.initAtSerial} with local serverShadow at ${state.serverShadow.serial}`,\n                    )\n\n                const initialBoard = {\n                    ...state.serverShadow,\n                    ...event.boardAttributes,\n                } as Board\n\n                const queue = state.queue.filter(isBoardHistoryEntry) // Discard old cursor events etc, but keep any offline board events that need to be sent\n\n                if (initialServerSyncEventBuffer.length > 0) {\n                    console.log(\n                        `Going to online mode. Init at ${event.initAtSerial} with ${\n                            initialServerSyncEventBuffer.length\n                        } new events. Board starts at ${initialBoard.serial} and first event is ${\n                            initialServerSyncEventBuffer[0].serial\n                        } and last ${initialServerSyncEventBuffer[initialServerSyncEventBuffer.length - 1].serial}. ${\n                            queue.length\n                        } local events queued, ${state.sent.length} awaiting ack.`,\n                    )\n                } else {\n                    console.log(\n                        `Init at ${event.initAtSerial}, no new events. ${queue.length} local events queued, ${state.sent.length} awaiting ack.`,\n                    )\n                }\n                // New server shadow = old server shadow + recent events from server\n                const newServerShadow = initialServerSyncEventBuffer.reduce(\n                    (b, e) => boardReducer(b, e)[0],\n                    initialBoard,\n                )\n                // Local board = server shadow + local queue\n                const board = queue.reduce((b, e) => boardReducer(b, e)[0], newServerShadow)\n\n                initialServerSyncEventBuffer = []\n\n                return flushIfPossible({\n                    ...state,\n                    accessLevel: event.accessLevel,\n                    status: \"online\",\n                    board,\n                    serverShadow: newServerShadow,\n                    sent: [], // Discard information about anything that was sent earlierly and to which we never got an ack for\n                    queue,\n                })\n            } catch (e) {\n                console.error(\"Error initializing board. Fetching as new board...\", e)\n                resetForBoard(boardId)\n                return {\n                    ...state,\n                    status: \"loading\",\n                    board: emptyBoard(boardId),\n                }\n            }\n        } else if (event.action === \"ui.offline\") {\n            if (state.status === \"online\" || state.status === \"joining\" || state.status === \"loading\") {\n                console.log(`Disconnected. Going to offline mode.`)\n            }\n            let board = state.board\n            if (state.sent.length > 0 && state.serverShadow) {\n                console.log(`Discarding ${state.sent.length} sent events of which we don't have an ack yet`)\n                // Roll back to serverShadow+queue, now that sent are discarded\n                board = state.queue\n                    .filter(isBoardHistoryEntry)\n                    .reduce((b, e) => boardReducer(b, e)[0], assertNotNull(state.serverShadow))\n            }\n            return {\n                ...state,\n                board,\n                status: \"offline\",\n                sent: [], // Discard information about anything that was sent earlierly and to which we never got an ack for\n                users: [],\n                locks: {},\n            }\n        }\n        //console.warn(\"Unhandled event\", event.action);\n        return state\n    }\n\n    const initialState: BoardState = {\n        status: \"none\" as const,\n        accessLevel: \"none\",\n        serverShadow: undefined,\n        board: undefined,\n        locks: {},\n        users: [],\n        queue: [],\n        sent: [],\n    }\n\n    function tagWithUser(e: UIEvent): BoardHistoryEntry | ClientToServerRequest | LocalUIEvent {\n        return isPersistableBoardItemEvent(e) ? tagWithUserFromState(e) : e\n    }\n    const uiEvents = L.bus<UIEvent>()\n    const dispatch: Dispatch = uiEvents.push\n    const userTaggedLocalEvents = L.view(uiEvents, tagWithUser)\n    const events = L.merge(userTaggedLocalEvents, connection.bufferedServerEvents)\n    const state = events.pipe(L.scan(initialState, eventsReducer, globalScope))\n\n    // persistable events and undo/redo are put to the state queue above, others are sent here immediately\n    uiEvents\n        .pipe(L.filter((e) => !isLocalUIEvent(e) && !isPersistableBoardItemEvent(e) && !isCursorMove(e)))\n        .forEach(connection.send)\n\n    const localBoardToSave = state.pipe(\n        L.changes,\n        L.filter((state) => {\n            if (state.serverShadow !== undefined && (state.status === \"online\" || state.status === \"offline\")) {\n                return true\n            }\n            return false\n        }),\n        L.map((state) => {\n            return {\n                serverShadow: assertNotNull(state.serverShadow),\n                queue: state.queue.filter(isBoardHistoryEntry),\n            }\n        }),\n    )\n    localBoardToSave.forEach(async (board) => {\n        await localStore.storeBoardState(board)\n    })\n\n    boardId.forEach(async (boardId) => {\n        // Reset board id in state, until we have fetched the local state\n        dispatch({ action: \"ui.board.setLocal\", boardId: undefined, storedInitialState: undefined })\n        if (boardId) {\n            console.log(\"Got board id, fetching local state\", boardId)\n            const storedInitialState = await localStore.getInitialBoardState(boardId)\n            if (storedInitialState && storedInitialState.serverShadow.id !== boardId) {\n                console.log(\"Abort: board id already changed\")\n                return\n            }\n            dispatch({ action: \"ui.board.setLocal\", boardId, storedInitialState }) // This is for the reducer locally to start offline mode\n            checkReadyToJoin()\n        }\n    })\n\n    const sessionStatus = L.view(sessionInfo, (s) => s.status)\n    const ss = sessionStatus.pipe(\n        L.changes,\n        L.scan([sessionStatus.get(), sessionStatus.get()] as const, ([a, b], next) => [b, next] as const),\n        L.applyScope(globalScope),\n    )\n    ss.onChange(([prev, s]) => {\n        if (s === \"logged-out\" && prev === \"logged-in\") {\n            localStore.clearAllPrivateBoards()\n            const board = state.get().board\n            if (board && board.accessPolicy) {\n                dispatch({ action: \"ui.board.logged.out\", boardId: board.id })\n                return\n            }\n            connection.newSocket()\n        }\n        checkReadyToJoin()\n    })\n    connection.connected.onChange((connected) => {\n        if (connected) {\n            checkReadyToJoin()\n        } else {\n            dispatch({ action: \"ui.offline\" })\n        }\n    })\n\n    function checkReadyToJoin() {\n        if (state.get().board && connection.connected.get() && !isLoginInProgress(sessionStatus.get())) {\n            // Go to online mode if we have a board id, are connected and not in the middle of login\n            dispatch({ action: \"ui.online\" })\n        }\n    }\n\n    const localBoardItemEvents = uiEvents.pipe(L.filter(isPersistableBoardItemEvent, globalScope))\n\n    const crdtStore = CRDTStore(\n        L.view(state, (s) => s.board?.id),\n        L.view(state, (s) => s.status === \"online\"),\n        localBoardItemEvents,\n        sessionInfo,\n        getWebSocketRootUrl,\n        WebSocketPolyfill,\n    )\n\n    return {\n        state,\n        events,\n        eventsFromServer: connection.bufferedServerEvents,\n        dispatch,\n        canUndo: undoStack.canPop,\n        canRedo: redoStack.canPop,\n        crdtStore,\n    }\n}\n\nexport function sessionState2UserInfo(state: UserSessionState): SessionUserInfo {\n    if (state.status === \"logged-in\") {\n        return {\n            userType: \"authenticated\",\n            email: state.email,\n            nickname: state.nickname,\n            name: state.name,\n            userId: state.userId,\n            domain: state.domain,\n            picture: state.picture,\n        }\n    } else {\n        return {\n            userType: \"unidentified\",\n            nickname: state.nickname || \"<unknown>\",\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/src/store/crdt-store.ts",
    "content": "import * as L from \"lonna\"\nimport { IndexeddbPersistence } from \"y-indexeddb\"\nimport { WebsocketProvider } from \"y-websocket\"\nimport * as Y from \"yjs\"\nimport { augmentItemsWithCRDT, getCRDTField, importItemsIntoCRDT } from \"../../../common/src/board-crdt-helper\"\nimport { Id, Item, PersistableBoardItemEvent } from \"../../../common/src/domain\"\nimport { getWebSocketRootUrl } from \"./server-connection\"\nimport { UserSessionState } from \"./user-session-store\"\n\ntype BoardCRDT = ReturnType<typeof BoardCRDT>\nexport type WebSocketPolyfill =\n    | {\n          new (url: string | URL, protocols?: string | string[] | undefined): WebSocket\n          prototype: WebSocket\n          readonly CLOSED: number\n          readonly CLOSING: number\n          readonly CONNECTING: number\n          readonly OPEN: number\n      }\n    | undefined\n\nfunction BoardCRDT(\n    boardId: Id,\n    online: L.Property<boolean>,\n    localBoardItemEvents: L.EventStream<PersistableBoardItemEvent>,\n    getSocketRoot: () => string,\n    WebSocketPolyfill: WebSocketPolyfill,\n) {\n    const doc = new Y.Doc()\n\n    function getField(itemId: Id, fieldName: string) {\n        return getCRDTField(doc, itemId, fieldName)\n    }\n\n    function augmentItems(items: Item[]): Item[] {\n        return augmentItemsWithCRDT(doc, items)\n    }\n\n    if (typeof indexedDB != \"undefined\") {\n        const persistence = new IndexeddbPersistence(`b/${boardId}`, doc)\n\n        persistence.on(\"synced\", () => {\n            console.log(\"CRDT data from indexedDB is loaded\")\n        })\n    }\n\n    const provider = new WebsocketProvider(`${getSocketRoot()}/socket/yjs`, `board/${boardId}`, doc, {\n        connect: online.get(),\n        WebSocketPolyfill,\n    })\n\n    const disconnected = L.bus()\n    online.pipe(L.changes, L.takeUntil(disconnected)).forEach((c) => (c ? provider.connect() : provider.disconnect()))\n\n    localBoardItemEvents\n        .pipe(\n            L.takeUntil(disconnected),\n            L.filter((e) => e.boardId === boardId),\n        )\n        .forEach((event) => {\n            if (event.action === \"item.add\") {\n                importItemsIntoCRDT(doc, event.items, { fallbackToText: true })\n            }\n        })\n\n    provider.on(\"status\", (event: any) => {\n        console.log(\"YJS Provider status\", boardId, event.status)\n    })\n\n    function disconnect() {\n        console.log(\"Disconnecting YJS provider for board\", boardId)\n        provider.destroy()\n    }\n\n    return {\n        boardId,\n        doc,\n        getField,\n        augmentItems,\n        disconnect,\n        awareness: provider.awareness,\n    }\n}\n\nexport type CRDTStore = ReturnType<typeof CRDTStore>\n\nexport function CRDTStore(\n    currentBoardId: L.Property<Id | undefined>,\n    online: L.Property<boolean>,\n    localBoardItemEvents: L.EventStream<PersistableBoardItemEvent>,\n    sessionState: L.Property<UserSessionState>,\n    getSocketRoot: () => string = getWebSocketRootUrl,\n    WebSocketPolyfill: WebSocketPolyfill = WebSocket as any,\n) {\n    let boardCrdt = L.atom<BoardCRDT | undefined>(undefined)\n\n    currentBoardId.forEach((boardId) => {\n        boardCrdt.modify((prev) => {\n            if (!prev || prev.boardId === boardId) {\n                return prev\n            }\n            prev.disconnect()\n        })\n    })\n\n    const userInfo = L.view(sessionState, (state) => ({\n        name: state.nickname,\n        color: \"#35b2dc\",\n    }))\n\n    L.view(userInfo, boardCrdt, (u, c) => [u, c] as const).forEach(([u, c]) => {\n        if (c) {\n            c.awareness.setLocalStateField(\"user\", u)\n        }\n    })\n\n    function getBoardCrdt(boardId: Id): BoardCRDT {\n        if (boardId != currentBoardId.get()) {\n            throw Error(`Requested CRDT for board ${boardId} but current board is ${currentBoardId.get()}`)\n        }\n\n        boardCrdt.modify((prev) => {\n            if (prev) {\n                return prev\n            }\n            return BoardCRDT(boardId, online, localBoardItemEvents, getSocketRoot, WebSocketPolyfill)\n        })\n        return boardCrdt.get()!\n    }\n\n    function augmentItems(boardId: Id, items: Item[]): Item[] {\n        const bc = boardCrdt.get()\n        if (!bc || bc.boardId !== boardId) {\n            return items\n        }\n        return bc.augmentItems(items)\n    }\n\n    return {\n        getBoardCrdt,\n        augmentItems,\n    }\n}\n"
  },
  {
    "path": "frontend/src/store/cursors-store.ts",
    "content": "import { ServerConnection } from \"./server-connection\"\nimport { CURSOR_POSITIONS_ACTION_TYPE, CursorPositions, UserCursorPosition, AppEvent } from \"../../../common/src/domain\"\nimport * as L from \"lonna\"\nimport { globalScope } from \"lonna\"\nimport { UserSessionStore } from \"./user-session-store\"\n\nexport function CursorsStore(connection: ServerConnection, sessionStore: UserSessionStore) {\n    const cursorUpdates = connection.bufferedServerEvents.pipe(\n        L.filter(isCursors),\n        L.map((event) => {\n            const otherCursors = { ...event.p }\n            const session = sessionStore.sessionId.get()\n            session && delete otherCursors[session] // Remove my own cursor. Server includes all because it's cheaper that way.\n            const cursors = Object.values(otherCursors)\n            return cursors\n        }),\n    )\n    const cursorReset = connection.connected.pipe(\n        L.changes,\n        L.filter((connected) => !connected),\n        L.map(() => [] as UserCursorPosition[]),\n    )\n\n    const cursors = L.merge([cursorUpdates, cursorReset]).pipe(L.toProperty([]), L.applyScope(globalScope))\n\n    let cursorsReceivedLast = 0\n    const cursorDelay = cursors.pipe(\n        L.map(() => {\n            const now = new Date().getTime()\n            const delay = cursorsReceivedLast ? now - cursorsReceivedLast : 0\n            cursorsReceivedLast = now\n            return delay\n        }),\n    )\n    return {\n        cursors,\n        cursorDelay,\n    }\n}\nexport type CursorsStore = ReturnType<typeof CursorsStore>\n\nfunction isCursors(e: AppEvent): e is CursorPositions {\n    return e.action === CURSOR_POSITIONS_ACTION_TYPE\n}\n"
  },
  {
    "path": "frontend/src/store/recent-boards.ts",
    "content": "import { Board, Id, newISOTimeStamp, RecentBoard, RecentBoardAttributes } from \"../../../common/src/domain\"\nimport * as L from \"lonna\"\nimport { ServerConnection } from \"./server-connection\"\nimport { getAuthenticatedUser, UserSessionStore } from \"./user-session-store\"\n\nexport function RecentBoards(connection: ServerConnection, sessionStore: UserSessionStore) {\n    let recentBoards = L.atom<RecentBoard[]>(localStorage.recentBoards ? JSON.parse(localStorage.recentBoards) : [])\n\n    function storeRecentBoard(board: RecentBoardAttributes) {\n        const userEmail = getAuthenticatedUser(sessionStore.sessionState.get())?.email || null\n        const recentBoard = { name: board.name, id: board.id, opened: newISOTimeStamp(), userEmail }\n        storeRecentBoardLocally(recentBoard)\n    }\n\n    function storeRecentBoardLocally(recentBoard: RecentBoard) {\n        storeRecentBoards((boards) => [recentBoard, ...boards.filter((b) => b.id !== recentBoard.id)])\n    }\n\n    function removeRecentBoard(board: RecentBoard) {\n        storeRecentBoards((boards) => boards.filter((b) => b.id !== board.id))\n        connection.send({ action: \"board.dissociate\", boardId: board.id })\n    }\n\n    function storeRecentBoards(fn: (boards: RecentBoard[]) => RecentBoard[]) {\n        recentBoards.modify(fn)\n        localStorage.recentBoards = JSON.stringify(recentBoards.get())\n    }\n\n    connection.bufferedServerEvents.subscribe((e) => {\n        if (e.action === \"user.boards\") {\n            // Board list from server, let's sync\n            const boardsFromServer = e.boards\n            const localBoards = recentBoards.get()\n            const boardsOnlyFoundLocally = localBoards.filter((b) => !boardsFromServer.some((bs) => bs.id === b.id))\n            const boardsToSend = boardsOnlyFoundLocally.filter((b) => !b.userEmail || b.userEmail === e.email)\n            boardsToSend.forEach((b) =>\n                connection.send({ action: \"board.associate\", boardId: b.id, lastOpened: b.opened }),\n            )\n            storeRecentBoards(() => [...boardsFromServer, ...boardsOnlyFoundLocally])\n        }\n    })\n\n    return {\n        storeRecentBoard,\n        recentboards: recentBoards as L.Property<RecentBoard[]>,\n        removeRecentBoard,\n    }\n}\n\nexport type RecentBoards = ReturnType<typeof RecentBoards>\n"
  },
  {
    "path": "frontend/src/store/server-connection.ts",
    "content": "import * as L from \"lonna\"\nimport { globalScope } from \"lonna\"\nimport { CURSORS_ONLY, addOrReplaceEvent } from \"../../../common/src/action-folding\"\nimport { EventFromServer, EventWrapper, UIEvent } from \"../../../common/src/domain\"\nimport { sleep } from \"../../../common/src/sleep\"\n\nexport type Dispatch = (e: UIEvent) => void\n\nconst SERVER_EVENTS_BUFFERING_MILLIS = 20\n\nexport type ServerConnection = ReturnType<typeof GenericServerConnection>\n\nexport type ConnectionStatus = \"connecting\" | \"connected\" | \"sleeping\" | \"reconnecting\" | \"offline\"\n\nexport function getWebSocketRootUrl() {\n    const WS_PROTOCOL = location.protocol === \"http:\" ? \"ws:\" : \"wss:\"\n    return `${WS_PROTOCOL}//${location.host}`\n}\n\nexport function BrowserSideServerConnection(boardId: L.Property<string | undefined>) {\n    const documentHidden = L.fromEvent(document, \"visibilitychange\").pipe(\n        L.toStatelessProperty(() => document.hidden || false),\n    )\n\n    const socketAddress = L.view(boardId, (id) =>\n        id ? `${getWebSocketRootUrl()}/socket/board/${id}` : `${getWebSocketRootUrl()}/socket/lobby`,\n    )\n\n    //const root = \"wss://www.ourboard.io\"\n    //const root = \"ws://localhost:1339\"\n    return GenericServerConnection(socketAddress, documentHidden, (s) => new WebSocket(s))\n}\n\nexport function GenericServerConnection(\n    initialSocketAddress: L.Property<string>,\n    documentHidden: L.Property<boolean>,\n    createSocket: (address: string) => WebSocket,\n) {\n    const serverEvents = L.bus<EventFromServer>()\n    const bufferedServerEvents = serverEvents.pipe(\n        L.bufferWithTime(SERVER_EVENTS_BUFFERING_MILLIS),\n        L.flatMap((events) => {\n            return L.fromArray(\n                events.reduce((folded, next) => addOrReplaceEvent(next, folded, CURSORS_ONLY), [] as EventFromServer[]),\n            )\n        }, globalScope),\n    )\n\n    const connectionStatus = L.atom<ConnectionStatus>(\"connecting\")\n    const forceOffline = L.atom<boolean>(false)\n    let currentSocketAddress: string | undefined = undefined\n    let socket: WebSocket | null = null\n    initialSocketAddress.forEach((newAddress) => {\n        currentSocketAddress = newAddress\n        newSocket()\n    })\n\n    setInterval(() => {\n        if (documentHidden.get() && connectionStatus.get() === \"connected\" && socket) {\n            console.log(\"Document hidden, closing socket\")\n            connectionStatus.set(\"sleeping\")\n            socket.close()\n        } else {\n            send({ action: \"ping\" })\n        }\n    }, 30000)\n\n    documentHidden.onChange((hidden) => {\n        if (!hidden && connectionStatus.get() === \"sleeping\" && !forceOffline.get()) {\n            console.log(\"Document shown, reconnecting.\")\n            newSocket()\n        }\n    })\n\n    function initSocket() {\n        if (forceOffline.get() || currentSocketAddress === undefined) {\n            return null\n        }\n        connectionStatus.set(\"connecting\")\n        console.log(\"Connecting to \" + currentSocketAddress)\n        const ws = createSocket(currentSocketAddress)\n        ws.addEventListener(\"error\", (e) => {\n            if (ws === socket) {\n                console.error(\"Web socket error\")\n                reconnect()\n            }\n        })\n        ws.addEventListener(\"open\", () => {\n            console.log(\"Socket connected\")\n            connectionStatus.set(\"connected\")\n        })\n        ws.addEventListener(\"message\", (str) => {\n            const event = JSON.parse(str.data) as EventFromServer\n            if (event.action === \"board.join.denied\" && event.reason === \"redirect\") {\n                currentSocketAddress = event.wsAddress\n                newSocket()\n            } else {\n                serverEvents.push(event)\n            }\n        })\n\n        ws.addEventListener(\"close\", () => {\n            if (ws === socket) {\n                console.log(\"Socket disconnected\")\n                reconnect()\n            }\n        })\n\n        forceOffline.pipe(L.changes, L.take(1)).forEach((f) => f && ws.close())\n        return ws\n\n        async function reconnect() {\n            if (forceOffline.get()) {\n                connectionStatus.set(\"offline\")\n            } else if (documentHidden.get()) {\n                connectionStatus.set(\"sleeping\")\n            } else {\n                connectionStatus.set(\"reconnecting\")\n                await sleep(1000)\n                if (ws === socket) {\n                    console.log(\"reconnecting...\")\n                    newSocket()\n                }\n            }\n        }\n    }\n\n    function newSocket() {\n        socket?.close()\n        socket = initSocket()\n    }\n\n    forceOffline.pipe(L.changes).forEach((f) => !f && newSocket())\n\n    function send(e: UIEvent | EventWrapper) {\n        //console.log(\"Sending\", e)\n        let wrapper: EventWrapper\n        if (\"action\" in e) {\n            sentUIEvents.push(e)\n            wrapper = { events: [e] }\n        } else {\n            wrapper = e\n        }\n        try {\n            socket?.send(JSON.stringify(wrapper))\n        } catch (e) {\n            console.error(\"Failed to send\", e) // TODO\n        }\n    }\n    const sentUIEvents = L.bus<UIEvent>()\n    if (typeof window !== \"undefined\") {\n        ;(window as any).forceOffline = forceOffline\n    }\n\n    return {\n        send,\n        bufferedServerEvents,\n        sentUIEvents: sentUIEvents as L.EventStream<UIEvent>,\n        connected: L.view(connectionStatus, (s) => s === \"connected\"),\n        newSocket,\n    }\n}\n"
  },
  {
    "path": "frontend/src/store/user-session-store.ts",
    "content": "import * as L from \"lonna\"\nimport { globalScope } from \"lonna\"\nimport { OAuthAuthenticatedUser } from \"../../../common/src/authenticated-user\"\nimport { AppEvent, BoardAccessPolicy, Id } from \"../../../common/src/domain\"\nimport { signIn } from \"../google-auth\"\nimport { ServerConnection } from \"./server-connection\"\nimport jwtDecode from \"jwt-decode\"\nimport Cookies from \"js-cookie\"\n\nexport type UserSessionState = Anonymous | LoggingInServer | LoggedIn | LoggedOut | LoginFailedDueToTechnicalProblem\n\nexport type StateId = UserSessionState[\"status\"]\n\nexport type BaseSessionState = {\n    sessionId: Id | null\n    nickname: string | undefined\n    nicknameSetByUser: boolean\n}\n\nexport type Anonymous = BaseSessionState & {\n    status: \"anonymous\"\n    loginSupported: boolean\n}\n\nexport type LoggedOut = BaseSessionState & {\n    status: \"logged-out\"\n}\n\nexport type LoginFailedDueToTechnicalProblem = BaseSessionState & {\n    status: \"login-failed\"\n}\n\n/**\n *  Locally OAUTH authenticated but auth with server pending\n **/\nexport type LoggingInServer = BaseSessionState &\n    OAuthAuthenticatedUser & {\n        status: \"logging-in-server\"\n        nickname: string\n    }\n\nexport type LoggedIn = BaseSessionState &\n    OAuthAuthenticatedUser & {\n        status: \"logged-in\"\n        nickname: string\n        userId: string\n    }\n\nexport type UserSessionStore = ReturnType<typeof UserSessionStore>\n\nexport function UserSessionStore(connection: ServerConnection, localStorage: Storage) {\n    const eventsReducer = (state: UserSessionState, event: AppEvent | boolean): UserSessionState => {\n        if (typeof event === \"boolean\") {\n            // Connected = true, Disconnected = false\n            const newState = state.status === \"logged-in\" ? { ...state, status: \"logging-in-server\" as const } : state\n            if (event === true) {\n                sendLoginAndNickname(newState)\n            }\n            return newState\n        } else if (\"action\" in event) {\n            if (event.action === \"server.config\" && state.status === \"anonymous\") {\n                return { ...state, loginSupported: event.authSupported }\n            } else if (event.action === \"board.join.ack\") {\n                Cookies.set(\"sessionId\", event.sessionId, { sameSite: \"strict\" })\n                return { ...state, sessionId: event.sessionId, nickname: state.nickname || event.nickname }\n            } else if (event.action === \"nickname.set\") {\n                const nickname = storeNickName(event.nickname)\n                return { ...state, nickname, nicknameSetByUser: true }\n            } else if (event.action === \"auth.login.response\") {\n                if (!event.success) {\n                    console.log(\"Server denied login - redirecting back to login screen\")\n                    signIn()\n                } else if (state.status === \"logging-in-server\") {\n                    console.log(\"Successfully logged in\")\n                    return { ...state, status: \"logged-in\", userId: event.userId }\n                } else {\n                    console.warn(`Login response in unexpected state ${state.status}`)\n                }\n                return state\n            } else {\n                // Ignore other events\n                return state\n            }\n        } else {\n            return state\n        }\n    }\n\n    function sendLoginAndNickname(state: UserSessionState) {\n        if (state.status === \"logging-in-server\" || state.status === \"logged-in\") {\n            //console.log(\"Send nick & login\")\n            connection.send({ action: \"nickname.set\", nickname: state.name })\n            connection.send({\n                action: \"auth.login.jwt\",\n                jwt: getUserJWT()!,\n            })\n        } else if (state.nickname) {\n            //console.log(\"Send nick\")\n            connection.send({ action: \"nickname.set\", nickname: state.nickname })\n        }\n        return state\n    }\n\n    const userFromCookie = getAuthenticatedUserFromCookie()\n\n    const initialState: UserSessionState = !userFromCookie\n        ? {\n              status: \"anonymous\",\n              sessionId: null,\n              nickname: localStorage.nickname || undefined,\n              nicknameSetByUser: !!localStorage.nickname,\n              loginSupported: false,\n          }\n        : {\n              status: \"logging-in-server\",\n              sessionId: null,\n              nickname: localStorage.nickname || undefined,\n              nicknameSetByUser: !!localStorage.nickname,\n              ...userFromCookie,\n          }\n\n    const sessionState = L.merge(\n        connection.bufferedServerEvents,\n        connection.sentUIEvents,\n        connection.connected.pipe(L.changes),\n    ).pipe(L.scan(initialState, eventsReducer, globalScope))\n\n    L.view(sessionState, \"status\").log(\"Session status\")\n\n    // TODO: separate sessionId from board join (include in server hello)\n    const sessionId = L.view(sessionState, \"sessionId\")\n\n    return {\n        sessionState,\n        sessionId,\n    }\n\n    function storeNickName(nickname: string) {\n        localStorage.nickname = nickname\n        return nickname\n    }\n}\n\nfunction getAuthenticatedUserFromCookie(): OAuthAuthenticatedUser | null {\n    const userCookie = getUserJWT()\n    if (userCookie) {\n        try {\n            return jwtDecode(userCookie) as OAuthAuthenticatedUser\n        } catch (e) {\n            console.warn(\"Token parsing failed\", userCookie, e)\n        }\n    }\n    return null\n}\n\nfunction getUserJWT() {\n    return Cookies.get(\"user\") ?? null\n}\n\nexport function canLogin(state: UserSessionState): boolean {\n    if (state.status === \"logged-out\" || state.status === \"login-failed\") return true\n    if (state.status === \"anonymous\" && state.loginSupported) return true\n    return false\n}\n\nexport function isLoginInProgress(state: StateId): boolean {\n    return state === \"logging-in-server\"\n}\n\nexport function getAuthenticatedUser(state: UserSessionState): OAuthAuthenticatedUser | null {\n    if (state.status === \"logged-in\" || state.status === \"logging-in-server\") {\n        return state\n    } else {\n        return null\n    }\n}\n\nexport function defaultAccessPolicy(sessionState: UserSessionState, restrictAccess: boolean): BoardAccessPolicy {\n    if (sessionState.status === \"logged-in\") {\n        return {\n            allowList: [{ email: sessionState.email, access: \"admin\" }],\n            publicRead: !restrictAccess,\n            publicWrite: !restrictAccess,\n        }\n    } else {\n        return undefined\n    }\n}\n"
  },
  {
    "path": "frontend/src/style/board.scss",
    "content": "#root.board-container {\n    width: 100%;\n    height: calc(100vh - 2em);\n    display: flex;\n    flex-direction: column;\n    &:not(.online, .offline) {\n        header .controls,\n        > .content-container > .scroll-container > .border-container > .board,\n        header #board-info,\n        .tool-layer {\n            .board-tool,\n            > .history {\n                @extend .disabled-interaction;\n            }\n        }\n        > .minimap {\n            display: none;\n        }\n    }\n\n    &.embedded {\n        font-size: 100%;\n        header,\n        .navigation-toolbar,\n        .history,\n        .zoom-toolbar,\n        .undo-redo-toolbar,\n        .minimap {\n            display: none !important;\n        }\n        > .content-container > .scroll-container {\n            overflow: hidden; /* no scroll */\n            .border-container {\n                padding: 0;\n            }\n        }\n    }\n\n    * {\n        box-sizing: border-box;\n        user-select: none;\n    }\n\n    *[contenteditable=\"true\"] {\n        // Need this for safari\n        // https://developer.mozilla.org/en-US/docs/Web/CSS/user-select\n        -webkit-user-select: text;\n        user-select: text;\n    }\n\n    > .content-container {\n        flex-grow: 1;\n        height: 80%;\n        position: relative;\n        margin-top: $header-height;\n        @media (max-width: $narrow-screen-breakpoint) {\n            margin-top: $header-height + $fixed-toolbar-height;\n        }\n        > .scroll-container {\n            height: 100%;\n            overflow: auto;\n            background: $off-board-color;\n            .border-container {\n                box-sizing: content-box;\n            }\n        }\n    }\n\n    .board {\n        background-color: $off-board-color-just-a-little-bit-darker;\n        background-image: linear-gradient(\n                45deg,\n                $off-board-color 25%,\n                transparent 25%,\n                transparent 75%,\n                $off-board-color 75%\n            ),\n            linear-gradient(45deg, $off-board-color 25%, transparent 25%, transparent 75%, $off-board-color 75%);\n        background-size: 60em 60em;\n        background-position: 0 0, 30em 30em;\n        width: 100%;\n        height: 100%;\n        position: relative;\n        &.pan {\n            cursor: grab;\n\n            &:active {\n                cursor: grabbing; /* FIXME: doesnt work on chrome even with vendor prefixing */\n            }\n        }\n\n        .selection-control {\n            display: block;\n            pointer-events: none;\n            border: 2px solid $link-color;\n            position: absolute;\n            z-index: $z-index-selection;\n            .corner-resize-drag {\n                pointer-events: all;\n                @extend .dropshadow-shallow;\n                position: absolute;\n                display: block;\n                width: 10px;\n                height: 10px;\n                .touch & {\n                    width: max(0.8rem, min(1.5rem, 1em));\n                    height: max(0.8rem, min(1.5rem, 1em));\n                }\n                background: white;\n                border-radius: 20%;\n                border: 1px solid #cccccc;\n                $corner-offset: -7px;\n\n                &.left.top,\n                &.right.bottom {\n                    cursor: nwse-resize;\n                }\n                &.left.bottom,\n                &.right.top {\n                    cursor: nesw-resize;\n                }\n                &.left {\n                    left: $corner-offset;\n                }\n                &.right {\n                    right: $corner-offset;\n                }\n                &.top {\n                    top: $corner-offset;\n                }\n                &.bottom {\n                    bottom: $corner-offset;\n                }\n            }\n        }\n\n        .item {\n            display: inline-flex;\n            align-items: center;\n            justify-content: center;\n            cursor: pointer;\n            > .shape {\n                position: absolute;\n                z-index: -1;\n                box-shadow: 5px 5px 15px 0px rgba(0, 0, 0, 0.12);\n                width: 100%;\n                height: 100%;\n                top: 0;\n                left: 0;\n                transition: all 0.1s;\n                &.round {\n                    border-radius: 50%;\n                }\n                &.diamond {\n                    transform: rotate(45deg) scale(0.707);\n                }\n            }\n\n            &.color-ffffff00,\n            &.color-none {\n                > .shape {\n                    box-shadow: none;\n                    border: 1px dashed #00000040;\n                }\n            }\n\n            > .quill-wrapper {\n                width: 100%;\n                display: flex;\n                padding: 0;\n\n                * {\n                    user-select: initial;\n                }\n\n                > .quill-editor {\n                    width: 100%;\n                    height: fit-content;\n                    overflow: visible;\n                    > .ql-editor {\n                        padding: 0.1em;\n                        ol,\n                        ul {\n                            padding: 0;\n                        }\n                    }\n                    .ql-cursors {\n                        .ql-cursor-name {\n                            margin: 0.1em 0.4em 0.05em;\n                            font-size: min(1rem, 0.7em);\n                        }\n                    }\n                }\n            }\n\n            &.text {\n                > .quill-wrapper {\n                    height: 100%;\n                }\n            }\n        }\n\n        .note {\n            @extend .item;\n            font-family: $font-family-note;\n            text-align: center;\n\n            &.locked {\n                opacity: 0.5;\n            }\n\n            small {\n                display: block;\n                font-size: 0.3em;\n            }\n            .author {\n                z-index: -1;\n                font-family: $font-family;\n                position: absolute;\n                font-size: 0.5em;\n                bottom: 0.2em;\n                right: 0.2em;\n                white-space: nowrap;\n                overflow: hidden;\n                max-width: 100%;\n                opacity: 0.5;\n            }\n        }\n\n        > .text {\n            @extend .item;\n            box-shadow: none;\n            align-items: flex-start;\n            justify-content: flex-start;\n\n            > .shape {\n                border: none !important;\n                box-shadow: none;\n            }\n        }\n\n        .item .editable {\n            outline: none;\n        }\n\n        $drag-edge-width: 1rem;\n\n        .video,\n        .container {\n            .edge-drag {\n                background: transparent;\n                cursor: grab;\n                position: absolute;\n\n                &.left {\n                    top: 0;\n                    left: 0;\n                    height: 100%;\n                    width: $drag-edge-width;\n\n                    &:hover {\n                        background: linear-gradient(90deg, rgba(0, 0, 0, 0.1) 0%, transparent 100%);\n                    }\n                }\n                &.right {\n                    top: 0;\n                    right: 0;\n                    height: 100%;\n                    width: $drag-edge-width;\n\n                    &:hover {\n                        background: linear-gradient(270deg, rgba(0, 0, 0, 0.1) 0%, transparent 100%);\n                    }\n                }\n                &.top {\n                    top: 0;\n                    left: 0;\n                    width: 100%;\n                    height: $drag-edge-width;\n\n                    &:hover {\n                        background: linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, transparent 100%);\n                    }\n                }\n                &.bottom {\n                    bottom: 0;\n                    left: 0;\n                    width: 100%;\n                    height: $drag-edge-width;\n\n                    &:hover {\n                        background: linear-gradient(0deg, rgba(0, 0, 0, 0.1) 0%, transparent 100%);\n                    }\n                }\n            }\n        }\n\n        .container {\n            @extend .item;\n            cursor: initial;\n            align-items: flex-start;\n            justify-content: flex-start;\n\n            > .text {\n                margin-left: 0.5em;\n                margin-top: 0.5em;\n                z-index: 1;\n            }\n\n            > .hidden-contents-indicator {\n                position: absolute;\n                width: 100%;\n                height: 100%;\n                display: flex;\n                opacity: 0.1;\n                justify-content: center;\n                align-items: center;\n                > svg {\n                    width: 20%;\n                }\n            }\n        }\n\n        .image {\n            @extend .item;\n            img {\n                width: 100%;\n                height: 100%;\n            }\n            &.locked {\n                opacity: 0.5;\n            }\n        }\n\n        .video {\n            @extend .item;\n            video {\n                width: calc(100% - 2 * #{$drag-edge-width});\n                height: calc(100% - 2 * #{$drag-edge-width});\n            }\n        }\n\n        .cursor {\n            z-index: $z-index-cursors;\n            pointer-events: none;\n            position: absolute;\n            display: block;\n            transition-timing-function: ease-in-out;\n            &.stale {\n                animation: 10s 1 fadeout;\n                opacity: 0.1;\n            }\n            @keyframes fadeout {\n                0% {\n                    opacity: 1;\n                }\n                100% {\n                    opacity: 0.1;\n                }\n            }\n            .arrow {\n                transform: rotate(-35deg);\n                display: block;\n                width: 0px;\n                height: 0 px;\n                border-left: 5px solid transparent;\n                border-right: 5px solid transparent;\n                border-bottom: 10px solid $link-color;\n            }\n            .userInfo {\n                display: flex;\n                align-items: center;\n                margin-left: 0.6rem;\n                img {\n                    border-radius: 50%;\n                    max-width: 1rem;\n                }\n                .text {\n                    font-size: 0.5rem;\n                    margin-left: 0.3em;\n                }\n            }\n        }\n\n        .rectangular-selection {\n            pointer-events: none;\n            position: absolute;\n            border: 1px solid $link-color;\n            background: #35b2dc33;\n            z-index: $z-index-cursors;\n        }\n    }\n\n    .context-menu-positioner {\n        z-index: $z-index-menu;\n        position: absolute;\n        pointer-events: none;\n\n        > * {\n            font-size: 1rem;\n            .touch & {\n                font-size: 1.7rem;\n            }\n        }\n\n        .context-menu {\n            pointer-events: all;\n            padding: 0.5em;\n            border: 1px solid #cccccc;\n            border-radius: 2px;\n            background: white;\n            &.hidden {\n                display: none;\n            }\n            display: grid;\n            grid-column-gap: 0.5em;\n            grid-row-gap: 0.5em;\n            grid-auto-flow: column;\n\n            cursor: pointer;\n\n            .icon-group {\n                grid-auto-flow: column;\n            }\n        }\n\n        &.item-bottom {\n            .context-menu {\n                margin-top: 0.8em;\n            }\n            .submenu {\n                top: 3.1em;\n            }\n        }\n        &.item-top {\n            .context-menu {\n                margin-top: -2.5em;\n            }\n            .submenu {\n                bottom: 2.7em;\n            }\n        }\n\n        .icon-group {\n            display: inline-grid;\n            grid-column-gap: 0.5em;\n            grid-row-gap: 0.5em;\n        }\n\n        .icon.color {\n            height: 1em;\n            width: 1em;\n            display: inline-block;\n            &.white,\n            &.transparent {\n                border: 1px solid #eeeeee;\n                background: repeating-conic-gradient(#dddddd 0% 25%, transparent 0% 50%) 50%/0.5em 0.5em;\n            }\n\n            &.new-color {\n                position: relative;\n                background: conic-gradient(\n                        #ff5555 0%,\n                        #ffaa55 15%,\n                        #ffff55 30%,\n                        #55ff55 45%,\n                        #5555ff 60%,\n                        #aa55ff 75%,\n                        #ff55ff 90%,\n                        #ff5555 100%\n                    )\n                    50%;\n                border-radius: 50%;\n\n                > input[type=\"color\"] {\n                    position: absolute;\n                    width: 100%;\n                    height: 100%;\n                    opacity: 0;\n                    left: 0;\n                    top: 0;\n                }\n            }\n        }\n\n        .icon.disabled {\n            color: $disabled-color;\n            pointer-events: none;\n        }\n\n        .connection-ends {\n            .icon {\n                display: flex;\n                justify-content: center;\n                align-items: center;\n            }\n        }\n\n        .submenu {\n            pointer-events: all;\n            padding: 0.5em;\n            border: 1px solid #cccccc;\n            border-radius: 2px;\n            background: white;\n            display: flex;\n            position: absolute;\n            align-items: flex-start;\n            left: 0;\n            margin: 0;\n\n            > *:not(:first-child) {\n                margin-left: 1em;\n            }\n\n            .colors {\n                grid-template-columns: repeat(4, 1fr);\n            }\n\n            .shapes {\n                grid-template-columns: repeat(2, 1fr);\n            }\n\n            &.alignment.y {\n                left: 1.5em;\n            }\n\n            &.alignment.x {\n                .icon-group {\n                    grid-auto-flow: column;\n                }\n            }\n        }\n    }\n\n    svg.connections {\n        .connection {\n            stroke: $connection-color;\n            stroke-width: max(0.1em, 1.5px);\n            stroke-linecap: round;\n            fill: transparent;\n\n            &.selected {\n                stroke: $link-color;\n            }\n        }\n    }\n\n    .connection-node {\n        display: block;\n        width: max(0.4em, 6px);\n        height: max(0.4em, 6px);\n        cursor: pointer;\n        position: absolute;\n\n        &.black-dot-style {\n            background: $connection-color;\n            border-radius: 50%;\n            $corner-offset: -7px;\n            width: max(0.3em, 6px);\n            height: max(0.3em, 6px);\n            &.highlight {\n                background: $link-color;\n            }\n        }\n\n        &.arrow-style {\n            box-shadow: none;\n            background: transparent;\n\n            width: 0;\n            height: 0;\n            border-top: max(0.2em, 3px) solid transparent;\n            border-left: max(0.6em, 8px) solid $connection-color;\n            border-bottom: max(0.2em, 3px) solid transparent;\n\n            &.highlight {\n                border-top: max(0.3em, 4.5px) solid transparent;\n                border-left: max(0.9em, 12px) solid $link-color;\n                border-bottom: max(0.3em, 4.5px) solid transparent;\n            }\n        }\n    }\n\n    .connection-node-grabber-helper {\n        position: absolute;\n        transform: translate(-50%, -50%);\n        z-index: 100000;\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        background: transparent;\n        width: min(4em, 2rem);\n        height: min(4em, 2rem);\n        border-radius: 50%;\n        cursor: pointer;\n        .board:not(.connect) & {\n            &:hover {\n                background: rgba(0, 0, 0, 0.1);\n            }\n        }\n    }\n\n    .board.connect {\n        .item {\n            &:hover {\n                border: 1px solid black;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/src/style/dashboard.scss",
    "content": "#root.dashboard {\n    background-color: #f4f4f6;\n    display: flex;\n    align-items: center;\n    justify-content: flex-start;\n    font-size: 16px;\n\n    .sponsor {\n        position: fixed;\n        bottom: 40px;\n        left: 40px;\n        align-items: center;\n        display: flex;\n        gap: 6px;\n        img {\n            height: 1em;\n        }\n    }\n\n    .content {\n        z-index: 2;\n        max-width: min(30em, 100vw);\n        margin: 4em 1em 0;\n        .touch & {\n            width: 95vw;\n        }\n    }\n    a {\n        text-decoration: none;\n    }\n    header {\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n        > * {\n            margin: 0;\n        }\n        p {\n            text-align: center;\n        }\n        h1 {\n            font-size: 3em;\n            margin-bottom: 1rem;\n        }\n    }\n    .user-info {\n        margin-top: 4em;\n        display: flex;\n        justify-content: flex-end;\n        font-size: 0.8em;\n        a {\n            margin-left: 1ch;\n        }\n        margin-bottom: 0.5em;\n    }\n    main {\n        padding: 2em;\n        background: white;\n        border-radius: 11px;\n        a {\n            color: $orange;\n        }\n    }\n    ul {\n        list-style-type: none;\n        padding: 0;\n        margin: 0;\n        .templates {\n            display: flex;\n            flex-direction: column;\n            align-items: flex-start;\n            justify-content: space-between;\n        }\n    }\n\n    .user-content {\n        display: flex;\n    }\n\n    .recent-boards {\n        h2 a.edit {\n            margin-left: 1ch;\n            font-size: 0.6em;\n        }\n        &.filtered h2 a.edit {\n            display: none;\n        }\n        .search {\n            input {\n                outline: none;\n                margin-bottom: 1em;\n            }\n        }\n        .remove {\n            margin-left: 0.5em;\n            font-size: 0.6em;\n            visibility: hidden;\n            text-decoration: none;\n        }\n        .notouch & li:hover,\n        &.edit li {\n            .remove {\n                visibility: visible;\n            }\n        }\n        .view-options {\n            font-size: 0.8em;\n            margin-top: 1em;\n            a {\n                margin-right: 1em;\n            }\n        }\n    }\n    .create-board {\n        box-sizing: border-box;\n        max-width: min(100vw, 800px);\n        display: flex;\n        align-items: flex-start;\n        flex-direction: column;\n\n        .input-and-button {\n            width: 100%;\n            margin-bottom: 0.3rem;\n            display: flex;\n            flex: 1;\n\n            input {\n                flex: 1;\n            }\n            button {\n                margin-left: 1rem;\n            }\n\n            .touch & {\n                flex-direction: column;\n                align-items: flex-start;\n                input {\n                    width: 100%;\n                }\n                button,\n                input {\n                    font-size: 1.2em;\n                    margin: 0;\n                }\n                button {\n                    margin-top: 0.5em;\n                    height: 1.8em;\n\n                    transition: all 0.3s;\n                    &:disabled {\n                        margin-top: 0;\n                        padding-top: 0;\n                        padding-bottom: 0;\n                        height: 0;\n                        opacity: 0;\n                    }\n                }\n            }\n        }\n\n        .anonymousBoardDisclaimer {\n            margin-top: 1em;\n        }\n    }\n}\n\n.board-access-editor {\n    [type=\"checkbox\"] {\n        margin-left: 0;\n        margin-right: 0.5em;\n    }\n\n    button {\n        height: 2rem;\n        min-width: 8rem;\n        max-width: 8rem;\n    }\n\n    .domain-restrict-details {\n        margin-left: 2.5em;\n        margin-top: 1em;\n        > label {\n            margin-bottom: 1em;\n            display: block;\n        }\n        .allow-public-read {\n            margin-top: 1em;\n            margin-left: -2.5em;\n        }\n    }\n\n    .input-and-button {\n        width: 100%;\n        margin-bottom: 0.3rem;\n        display: flex;\n        flex: 1;\n\n        input {\n            flex: 1;\n        }\n        button {\n            margin-left: 1rem;\n        }\n\n        justify-content: space-between;\n\n        .filled-entry {\n            display: flex;\n            align-items: center;\n            font-size: 0.8em;\n        }\n    }\n\n    .restrict-toggle {\n        display: flex;\n        font-size: 1rem;\n        margin-top: 0.5rem;\n        margin-bottom: 0.5rem;\n    }\n}\n"
  },
  {
    "path": "frontend/src/style/global.scss",
    "content": "html {\n    font-family: $font-family;\n    color: $black;\n    font-size: 16px;\n}\n\n#root {\n    min-height: 100vh;\n    display: flex;\n    flex-direction: column;\n}\n\na {\n    color: $link-color;\n    cursor: pointer;\n}\n\nh2 {\n    margin-top: 0;\n    font-size: 1.3em;\n    font-weight: normal;\n}\n\ninput {\n    font-size: 1.1rem;\n    user-select: text;\n}\ninput[type=\"text\"] {\n    -webkit-border-radius: 0;\n    border-radius: 0;\n    -webkit-box-shadow: none;\n    box-shadow: none;\n    -webkit-appearance: none;\n    border: solid 1px #d1d1d1;\n    outline: none;\n    padding: 0.2em 0.5ch;\n}\n\ntextarea {\n    font-size: 1rem;\n    font-family: $font-family;\n}\n\nbutton {\n    cursor: pointer;\n    font-size: 1rem;\n\n    border: 1px solid $orange;\n    color: $orange;\n    border-radius: 6px;\n    margin: 0;\n    padding: 0;\n    width: auto;\n    overflow: visible;\n    outline: none;\n\n    background: transparent;\n\n    /* Normalize `line-height`. Cannot be changed from `normal` in Firefox 4+. */\n    line-height: normal;\n\n    /* Corrects font smoothing for webkit */\n    -webkit-font-smoothing: inherit;\n    -moz-osx-font-smoothing: inherit;\n\n    /* Corrects inability to style clickable `input` types in iOS */\n    -webkit-appearance: none;\n\n    padding: 0.2em 2em;\n    max-height: 2em;\n\n    &.mini {\n        width: 1.5em;\n        margin: 0.3em;\n        padding: 0;\n    }\n\n    &:hover {\n        text-decoration: none;\n    }\n\n    &:disabled {\n        border-color: $disabled-color;\n        color: $disabled-color;\n    }\n}\n\nbody {\n    margin: 0;\n    padding: 0;\n    overscroll-behavior-x: none;\n}\n\nselect {\n    border: 0;\n    border-bottom: 1px solid $black;\n    font: 0.7em $font-family;\n\n    &:focus {\n        outline: 0;\n    }\n}\n"
  },
  {
    "path": "frontend/src/style/header.scss",
    "content": "#root.board-container.not-found header {\n    #board-info {\n        visibility: hidden;\n    }\n}\n#root.board-container header {\n    position: fixed;\n    top: 0;\n    white-space: nowrap;\n    border-bottom: 2px solid #e7e8e8;\n    font-size: 0.8em;\n    z-index: $z-index-top-menu;\n    padding: 0 0.8em;\n    height: $header-height;\n    width: 100%;\n    display: grid;\n    grid-template-columns: 1fr 2fr 1fr;\n    background: #fffffff8;\n    color: $black;\n\n    @media (max-width: $narrow-screen-breakpoint) {\n        border-bottom: none;\n    }\n\n    span[contenteditable] {\n        border: 2px solid transparent;\n        &[contenteditable=\"false\"]:hover,\n        &:focus {\n            border: 2px solid #2f80edd9;\n            outline: none;\n            font-size: 1rem;\n        }\n    }\n\n    .logo-area {\n        display: flex;\n        align-items: center;\n        a {\n            display: none;\n            @media (max-width: $narrow-screen-breakpoint) {\n                display: flex;\n                align-items: center;\n                .icon {\n                    vertical-align: top;\n                    margin-right: 0.5em;\n                }\n                text-decoration: none;\n                color: $black;\n            }\n        }\n    }\n\n    > #board-info {\n        display: flex;\n        align-items: center;\n        justify-content: center;\n        margin-left: 0.5em;\n        margin-left: 1rem;\n        #board-name {\n            margin-right: 0.5em;\n        }\n        small {\n            color: gray;\n            border-radius: 0.5em;\n            padding: 1px 0.5em;\n            font-size: 0.6em;\n            vertical-align: middle;\n        }\n        a svg {\n            height: 1em;\n        }\n        a:not(:first-of-type) svg {\n            margin-left: 0.21em;\n        }\n    }\n\n    .right-panel {\n        display: flex;\n        align-items: center;\n        justify-content: flex-end;\n        @media (max-width: $narrow-screen-breakpoint) {\n            .other-users,\n            .offline-status {\n                display: none;\n            }\n        }\n\n        .offline-status {\n            font-size: 0.8em;\n            opacity: 0.8;\n            cursor: pointer;\n        }\n\n        .other-users {\n            position: relative;\n            .pop-up {\n                position: absolute;\n                top: 1em;\n                right: 0;\n                min-width: 10em;\n                background-color: white;\n                border-radius: 0px 0px 3px 3px;\n                padding: 1.5em 1em 1em;\n                text-align: right;\n                display: none;\n                ul {\n                    li:first-child {\n                        margin-bottom: 0.5em;\n                    }\n                    list-style: none;\n                    padding: 0;\n                    margin: 0;\n                }\n                border-bottom: 2px solid #e7e8e8;\n            }\n            .youlink {\n                opacity: 0.5;\n                font-size: \"0.8em\";\n            }\n            &:hover {\n                .pop-up {\n                    display: block;\n                }\n            }\n        }\n    }\n\n    @media (max-width: 1024px) {\n        font-size: 0.8rem;\n    }\n\n    .user-info {\n        display: flex;\n        justify-content: flex-end;\n        align-items: center;\n        padding-left: 1em;\n        .icon {\n            cursor: pointer;\n            margin-right: 0.5em;\n            display: inline-flex;\n            img {\n                border-radius: 50%;\n            }\n            svg {\n                width: 100%;\n            }\n        }\n\n        &.logging-in-local,\n        &.logging-in-server {\n            @extend .disabled-interaction;\n        }\n        &.logged-in {\n            .icon {\n                height: 2em;\n                width: 2em;\n            }\n        }\n    }\n    > * {\n        color: $black;\n    }\n}\n"
  },
  {
    "path": "frontend/src/style/modal.scss",
    "content": "@import \"./variables.scss\";\n\n.modal-container {\n    background-color: rgba(200, 200, 200, 0.6);\n    position: fixed;\n    width: 100%;\n    height: 100%;\n    top: 0;\n    left: 0;\n    z-index: $z-index-modal;\n    .modal-dialog {\n        position: fixed;\n        background-color: white;\n        width: 80%;\n        max-height: 90%;\n        max-width: 744px;\n        top: 50%;\n        left: 50%;\n        transform: translate(-50%, -50%);\n        padding: 36px 40px 20px 40px;\n        border-radius: 6px;\n        overflow-y: auto;\n\n        .modal-close {\n            display: flex;\n            position: fixed;\n            top: 32px;\n            right: 32px;\n            filter: invert(22%) sepia(86%) saturate(7261%) hue-rotate(221deg) brightness(108%) contrast(92%);\n            border: 1px solid;\n            height: 32px;\n            width: 32px;\n            align-items: center;\n            border-radius: 50%;\n            cursor: pointer;\n            svg {\n                margin: auto;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "frontend/src/style/sharing-modal.scss",
    "content": "@import \"./variables.scss\";\n\n.modal-dialog .sharing {\n    h2:not(:first-of-type) {\n        margin-top: 1em;\n    }\n\n    .copied {\n        color: $orange;\n        font-size: 0.8em;\n        margin-left: 1em;\n    }\n}\n"
  },
  {
    "path": "frontend/src/style/tool-layer.scss",
    "content": "#root.board-container {\n    > .content-container > .tool-layer {\n        margin: 0;\n        z-index: $z-index-menu;\n        position: absolute;\n        pointer-events: none;\n        width: 100%;\n        height: 100%;\n        top: 0;\n\n        > * {\n            pointer-events: all;\n        }\n\n        &.read-only {\n            .main-toolbar,\n            .undo-redo-toolbar {\n                display: none;\n            }\n        }\n\n        .toolbar {\n            border: 2px solid $off-board-color;\n            box-shadow: 0px 2px 6px 0px #00263a0f;\n            display: inline-block;\n            background: white;\n            padding: 0.5em;\n            border-radius: 6px;\n        }\n\n        .navigation-toolbar {\n            @extend .toolbar;\n            position: absolute;\n            left: 1em;\n            top: 1em;\n            font-size: 0.8em;\n            a {\n                .icon {\n                    vertical-align: top;\n                    margin-right: 0.5em;\n                }\n                text-decoration: none;\n                color: $black;\n            }\n            @media (max-width: $narrow-screen-breakpoint) {\n                display: none;\n            }\n        }\n        .main-toolbar {\n            @extend .toolbar;\n            position: fixed;\n            display: flex;\n            align-items: center;\n\n            &.vertical {\n                padding: 0.5em 0.8em 0.2em 0.5em;\n                flex-direction: column;\n                top: 50%;\n                transform: translateY(-50%);\n            }\n\n            &.horizontal {\n                padding: 0.5em 0.4em 0.2em;\n                flex-direction: row;\n                top: 3rem;\n                left: 50%;\n                transform: translateX(-50%);\n                @media (max-width: $narrow-screen-breakpoint) {\n                    top: $header-height;\n                    width: 100%;\n                    justify-content: center;\n                    height: $fixed-toolbar-height;\n                    border-radius: 0;\n                    border: none;\n                }\n            }\n\n            .new-item,\n            .tool {\n                display: inline-flex;\n                flex-direction: column;\n                align-items: center;\n                position: relative;\n                margin: 0 0.2em 0.2em;\n                @media (max-width: $narrow-screen-breakpoint) {\n                    margin: 0 0.1em 0.2em;\n                }\n                .text {\n                    margin: 0;\n                    margin-top: 0.5em;\n                    font-size: 0.8em;\n                    text-align: center;\n                }\n                .icon {\n                    width: 3em;\n                    cursor: pointer;\n                    margin: 0;\n                    height: 3em;\n                    padding: 0.5em 0.5rem;\n                }\n                &.active {\n                    .icon {\n                        background: #f4f4f6;\n                    }\n                }\n            }\n\n            .duplicate {\n                .notouch & {\n                    @media (max-width: $narrow-screen-breakpoint) {\n                        display: none;\n                    }\n                }\n            }\n\n            .line {\n                @media (max-width: 500px) {\n                    display: none;\n                }\n            }\n\n            .tool.undo,\n            .tool.redo {\n                @media (min-width: $non-narrow-screen-breakpoint) {\n                    display: none;\n                }\n                .notouch & {\n                    @media (max-width: 422px) {\n                        display: none;\n                    }\n                }\n                .touch & {\n                    @media (max-width: 374px) {\n                        display: none;\n                    }\n                }\n            }\n\n            .tool.redo {\n                .notouch & {\n                    @media (max-width: 500px) {\n                        display: none;\n                    }\n                }\n                .touch & {\n                    @media (max-width: 554px) {\n                        display: none;\n                    }\n                }\n            }\n\n            .new-item.container {\n                .notouch & {\n                    @media (max-width: 374px) {\n                        display: none;\n                    }\n                }\n                .touch & {\n                    @media (max-width: 326px) {\n                        display: none;\n                    }\n                }\n            }\n\n            .new-item.text {\n                .notouch & {\n                    @media (max-width: 326px) {\n                        display: none;\n                    }\n                }\n                .touch & {\n                    @media (max-width: 278px) {\n                        display: none;\n                    }\n                }\n            }\n\n            .new-item {\n                .icon svg {\n                    margin-top: -0.5em; // The plus sign is out of bounds\n                    margin-right: -0.5em;\n                }\n            }\n        }\n\n        .tool-instruction {\n            pointer-events: none;\n            position: absolute;\n            top: 6em;\n            left: 1em;\n            right: 1em;\n            padding: 1em;\n            border-radius: 0.5em;\n            text-align: center;\n            color: #555555;\n            background: rgba(0, 0, 0, 0.1);\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            svg {\n                margin-left: 1em;\n                opacity: 0.3;\n            }\n        }\n\n        .undo-redo-toolbar {\n            @extend .toolbar;\n            position: absolute;\n            font-size: 1.2em;\n            left: 1em;\n            bottom: 1.5em;\n            padding-bottom: 0.2em;\n            @media (max-width: $narrow-screen-breakpoint) {\n                display: none;\n            }\n        }\n\n        .zoom-toolbar {\n            position: absolute;\n            font-size: 1.5em;\n            right: 0;\n            top: 100%;\n            color: #00263a;\n            left: 0;\n            padding: 0.3em;\n\n            .touch & {\n                display: none;\n            }\n        }\n\n        .zoom-controls {\n            display: flex;\n            justify-content: space-evenly;\n            height: 2rem;\n            align-items: center;\n        }\n\n        .minimap {\n            @extend .toolbar;\n            font-size: 1vw;\n            display: inline-block;\n            position: absolute;\n            bottom: 1rem;\n            right: 1rem;\n            background: white;\n            border: 0.3em solid #eeeeee88;\n            box-sizing: content-box;\n            border-bottom-width: 3rem;\n            min-width: 6rem;\n            @media (max-width: $narrow-screen-breakpoint) {\n                display: none;\n            }\n            .content {\n                position: relative;\n                .viewarea {\n                    display: inline-block;\n                    position: absolute;\n                    border: 1px solid $black;\n                }\n                .item {\n                    display: inline-block;\n                    position: absolute;\n                    background: $link-color;\n                    border: 2px solid $link-color;\n                    &.container {\n                        opacity: 0.5;\n                    }\n                }\n            }\n        }\n\n        .icon {\n            &:not(:first-child) {\n                margin-left: 0.3em;\n            }\n            cursor: pointer;\n            position: relative;\n        }\n\n        .board-status-message {\n            height: 100%;\n            width: 100%;\n            z-index: 100;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            text-align: center;\n            pointer-events: none;\n            > div {\n                pointer-events: all;\n                max-width: 80%;\n            }\n        }\n    }\n\n    > .content-container > .tool-layer > .history {\n        position: absolute;\n        right: 0;\n        top: 0;\n        bottom: 0;\n        display: flex;\n        flex-direction: column;\n        padding: 1rem;\n        h2 {\n            font-size: 1.2rem;\n            margin-bottom: 1em;\n        }\n        > .history-icon-wrapper {\n            display: flex;\n            flex-direction: column;\n            align-items: flex-start;\n        }\n        z-index: $z-index-menu;\n        min-width: 1rem;\n        &:not(.expanded) {\n            pointer-events: none;\n            .icon {\n                pointer-events: all;\n            }\n        }\n        &.expanded {\n            background: white;\n            border-left: 1px solid #cccccc;\n        }\n        > .selection {\n            margin-bottom: 1em;\n            font-size: 0.8em;\n        }\n        > .icon {\n            cursor: pointer;\n        }\n        .scroll-container {\n            overflow-y: auto;\n            table {\n                tr {\n                    vertical-align: top;\n                    .timestamp {\n                        min-width: 5em;\n                    }\n                    .action {\n                        min-width: 8em;\n                    }\n                }\n            }\n        }\n    }\n    .adding-item {\n        width: 2em;\n    }\n}\n"
  },
  {
    "path": "frontend/src/style/user-info-modal.scss",
    "content": "@import \"./variables.scss\";\n\n.modal-dialog .user-info {\n    .anonymous {\n        .nickname {\n            input {\n                margin-top: 1em;\n                display: block;\n                width: 100%;\n            }\n        }\n    }\n    .logged-in {\n        .icon {\n            width: 1em;\n            height: initial;\n            border-radius: 50%;\n        }\n    }\n    button {\n        margin-top: 1em;\n    }\n}\n"
  },
  {
    "path": "frontend/src/style/utils.scss",
    "content": ".dropshadow {\n    box-shadow: 3px 3px 15px 0px rgba(0, 0, 0, 0.16);\n}\n.dropshadow-shallow {\n    box-shadow: 2px 2px 5px 0px rgba(0, 0, 0, 0.16);\n}\n\n.textshadow {\n    text-shadow: -1px 2px 6px rgba(0, 0, 0, 0.3);\n}\n\n.disabled-interaction {\n    opacity: 0.3;\n    pointer-events: none;\n}\n\n.checkbox {\n    display: flex;\n    cursor: pointer;\n    width: 100%;\n    min-width: 1em;\n    gap: 1em;\n\n    > .icon {\n        background-image: url(img/unchecked-checkbox.png);\n        &.checked {\n            background-image: url(img/checked-checkbox.png);\n        }\n    }\n}\n\n.icon {\n    display: inline-block;\n    width: 1em;\n    min-width: 1em;\n    height: 1em;\n    background-size: contain;\n    background-repeat: no-repeat;\n    font-size: 1em !important;\n    &.disabled {\n        pointer-events: none;\n        color: $disabled-color;\n    }\n}\n"
  },
  {
    "path": "frontend/src/style/variables.scss",
    "content": "$black: #00263a;\n$off-board-color: #f4f4f6;\n$off-board-color-just-a-little-bit-darker: #f2f2f3;\n$link-color: #35b2dc;\n$heading-color: $link-color;\n$orange: #ff5544;\n$connection-color: #444444;\n\n$z-index-top-menu: 400000011;\n$z-index-menu: 400000010;\n$z-index-cursors: 40000008;\n$z-index-selection: 40000006;\n$z-index-modal: 400000020;\n\n$left-padding: 1em;\n$bottom-bar-height: 3em;\n$font-family: Raleway, sans-serif;\n$font-family-note: Raleway, sans-serif;\n$board-border-top: 2.8rem;\n$disabled-color: lightgrey;\n\n$header-height: 2.4rem;\n$fixed-toolbar-height: 5rem;\n$narrow-screen-breakpoint: 700px;\n$non-narrow-screen-breakpoint: 701px;\n"
  },
  {
    "path": "frontend/tsconfig.json",
    "content": "{\n    \"extends\": \"../tsconfig\",\n    \"compilerOptions\": {\n        \"target\": \"esnext\",\n        \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n        \"module\": \"esnext\",\n        \"resolveJsonModule\": true,\n        \"isolatedModules\": true,\n        \"jsx\": \"react\",\n        \"jsxFactory\": \"h\",\n        \"jsxFragmentFactory\": \"Fragment\",\n        \"outDir\": \"dist\",\n        \"sourceMap\": true\n    }\n}\n"
  },
  {
    "path": "integration/src/compact-history.test.ts",
    "content": "import { describe, expect, it, beforeAll, afterAll } from \"vitest\"\nimport { addHours, addSeconds } from \"date-fns\"\nimport _ from \"lodash\"\nimport { createBoard, getBoardHistoryBundleMetas, storeEventHistoryBundle } from \"../../backend/src/board-store\"\nimport { quickCompactBoardHistory } from \"../../backend/src/compact-history\"\nimport { closeConnectionPool, initDB, inTransaction, withDBClient } from \"../../backend/src/db\"\nimport { BoardHistoryEntry, EventUserInfo, Id, newBoard, newISOTimeStamp, Serial } from \"../../common/src/domain\"\ntype BundleDesc = [Date, Serial, Serial]\ndescribe(\"quick compact\", () => {\n    beforeAll(async () => {\n        await initDB(\"./backend\")\n    })\n    it(\"Compacts nearby events into single bundle\", async () => {\n        const board = newBoard(\"testboard\")\n        const boardId = board.id\n        await createBoard(board)\n        const firstSave = new Date()\n        const secondSave = addSeconds(firstSave, 1)\n        const origBundles: BundleDesc[] = [\n            [firstSave, 1, 2],\n            [secondSave, 3, 4],\n        ]\n        await storeBundles(boardId, origBundles)\n        expect(await getBundles(boardId)).toEqual(origBundles)\n        const compactions = await quickCompactBoardHistory(boardId)\n        expect(compactions).toEqual(1)\n        expect(await getBundles(boardId)).toEqual([[secondSave, 1, 4]])\n    })\n\n    it(\"Skips compaction in case bundles are separate from each other in time\", async () => {\n        const board = newBoard(\"testboard\")\n        const boardId = board.id\n        await createBoard(board)\n        const firstSave = new Date()\n        const secondSave = addHours(firstSave, 1)\n        const origBundles: BundleDesc[] = [\n            [firstSave, 1, 2],\n            [secondSave, 3, 4],\n        ]\n        await storeBundles(boardId, origBundles)\n        expect(await getBundles(boardId)).toEqual(origBundles)\n        const compactions = await quickCompactBoardHistory(boardId)\n        expect(compactions).toEqual(0)\n        expect(await getBundles(boardId)).toEqual(origBundles)\n    })\n\n    it(\"Groups by hour-of-day when one group needs compaction\", async () => {\n        const board = newBoard(\"testboard\")\n        const boardId = board.id\n        await createBoard(board)\n        const firstSave = new Date()\n        const secondSave = addHours(firstSave, 1)\n        const origBundles: BundleDesc[] = [\n            [firstSave, 1, 2],\n            [secondSave, 3, 4],\n            [secondSave, 5, 6],\n        ]\n        await storeBundles(boardId, origBundles)\n        expect(await getBundles(boardId)).toEqual(origBundles)\n        const compactions = await quickCompactBoardHistory(boardId)\n        expect(compactions).toEqual(1)\n        expect(await getBundles(boardId)).toEqual([\n            [firstSave, 1, 2],\n            [secondSave, 3, 6],\n        ])\n    })\n\n    it(\"Groups by hour-of-day when two groups need compaction\", async () => {\n        const board = newBoard(\"testboard\")\n        const boardId = board.id\n        await createBoard(board)\n        const firstSave = new Date()\n        const earlierSave = addHours(firstSave, -1)\n        const secondSave = addHours(firstSave, 1)\n        const laterSave = addHours(secondSave, 1)\n        const origBundles: BundleDesc[] = [\n            [earlierSave, 1, 1],\n            [firstSave, 2, 2],\n            [firstSave, 3, 3],\n            [secondSave, 4, 5],\n            [secondSave, 6, 7],\n            [laterSave, 8, 8],\n        ]\n        await storeBundles(boardId, origBundles)\n        expect(await getBundles(boardId)).toEqual(origBundles)\n        const compactions = await quickCompactBoardHistory(boardId)\n        expect(compactions).toEqual(2)\n\n        expect(await getBundles(boardId)).toEqual([\n            [earlierSave, 1, 1],\n            [firstSave, 2, 3],\n            [secondSave, 4, 7],\n            [laterSave, 8, 8],\n        ])\n    })\n\n    afterAll(closeConnectionPool)\n})\n\nasync function storeBundles(boardId: Id, bundles: BundleDesc[]) {\n    for (let [savedAt, firstSerial, lastSerial] of bundles) {\n        await addItems(boardId, firstSerial, lastSerial, savedAt)\n    }\n}\n\nasync function getBundles(boardId: Id) {\n    const bundles = await withDBClient((client) => getBoardHistoryBundleMetas(client, boardId))\n    return bundles.map((b) => [b.saved_at, b.first_serial, b.last_serial])\n}\n\nconst user: EventUserInfo = { userType: \"unidentified\", nickname: \"user1\" }\nasync function addItems(boardId: Id, firstSerial: Serial, lastSerial: Serial, savedAt: Date) {\n    const events: BoardHistoryEntry[] = _.range(firstSerial, lastSerial + 1).map((serial) => ({\n        action: \"item.add\",\n        boardId,\n        items: [],\n        connections: [],\n        timestamp: newISOTimeStamp(),\n        user,\n        serial,\n    }))\n    await inTransaction((client) =>\n        storeEventHistoryBundle(boardId, events, events[events.length - 1].serial!, null, client, savedAt),\n    )\n}\n"
  },
  {
    "path": "keycloak/README.md",
    "content": "This directory contains a database dump of an example Keycloak setup.\n"
  },
  {
    "path": "keycloak/keycloak-db.dump",
    "content": "--\n-- PostgreSQL database dump\n--\n\n-- Dumped from database version 12.12 (Debian 12.12-1.pgdg110+1)\n-- Dumped by pg_dump version 14.7 (Homebrew)\n\nSET statement_timeout = 0;\nSET lock_timeout = 0;\nSET idle_in_transaction_session_timeout = 0;\nSET client_encoding = 'UTF8';\nSET standard_conforming_strings = on;\nSELECT pg_catalog.set_config('search_path', '', false);\nSET check_function_bodies = false;\nSET xmloption = content;\nSET client_min_messages = warning;\nSET row_security = off;\n\nSET default_tablespace = '';\n\nSET default_table_access_method = heap;\n\n--\n-- Name: admin_event_entity; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.admin_event_entity (\n    id character varying(36) NOT NULL,\n    admin_event_time bigint,\n    realm_id character varying(255),\n    operation_type character varying(255),\n    auth_realm_id character varying(255),\n    auth_client_id character varying(255),\n    auth_user_id character varying(255),\n    ip_address character varying(255),\n    resource_path character varying(2550),\n    representation text,\n    error character varying(255),\n    resource_type character varying(64)\n);\n\n\nALTER TABLE public.admin_event_entity OWNER TO keycloak;\n\n--\n-- Name: associated_policy; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.associated_policy (\n    policy_id character varying(36) NOT NULL,\n    associated_policy_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.associated_policy OWNER TO keycloak;\n\n--\n-- Name: authentication_execution; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.authentication_execution (\n    id character varying(36) NOT NULL,\n    alias character varying(255),\n    authenticator character varying(36),\n    realm_id character varying(36),\n    flow_id character varying(36),\n    requirement integer,\n    priority integer,\n    authenticator_flow boolean DEFAULT false NOT NULL,\n    auth_flow_id character varying(36),\n    auth_config character varying(36)\n);\n\n\nALTER TABLE public.authentication_execution OWNER TO keycloak;\n\n--\n-- Name: authentication_flow; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.authentication_flow (\n    id character varying(36) NOT NULL,\n    alias character varying(255),\n    description character varying(255),\n    realm_id character varying(36),\n    provider_id character varying(36) DEFAULT 'basic-flow'::character varying NOT NULL,\n    top_level boolean DEFAULT false NOT NULL,\n    built_in boolean DEFAULT false NOT NULL\n);\n\n\nALTER TABLE public.authentication_flow OWNER TO keycloak;\n\n--\n-- Name: authenticator_config; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.authenticator_config (\n    id character varying(36) NOT NULL,\n    alias character varying(255),\n    realm_id character varying(36)\n);\n\n\nALTER TABLE public.authenticator_config OWNER TO keycloak;\n\n--\n-- Name: authenticator_config_entry; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.authenticator_config_entry (\n    authenticator_id character varying(36) NOT NULL,\n    value text,\n    name character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.authenticator_config_entry OWNER TO keycloak;\n\n--\n-- Name: broker_link; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.broker_link (\n    identity_provider character varying(255) NOT NULL,\n    storage_provider_id character varying(255),\n    realm_id character varying(36) NOT NULL,\n    broker_user_id character varying(255),\n    broker_username character varying(255),\n    token text,\n    user_id character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.broker_link OWNER TO keycloak;\n\n--\n-- Name: client; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client (\n    id character varying(36) NOT NULL,\n    enabled boolean DEFAULT false NOT NULL,\n    full_scope_allowed boolean DEFAULT false NOT NULL,\n    client_id character varying(255),\n    not_before integer,\n    public_client boolean DEFAULT false NOT NULL,\n    secret character varying(255),\n    base_url character varying(255),\n    bearer_only boolean DEFAULT false NOT NULL,\n    management_url character varying(255),\n    surrogate_auth_required boolean DEFAULT false NOT NULL,\n    realm_id character varying(36),\n    protocol character varying(255),\n    node_rereg_timeout integer DEFAULT 0,\n    frontchannel_logout boolean DEFAULT false NOT NULL,\n    consent_required boolean DEFAULT false NOT NULL,\n    name character varying(255),\n    service_accounts_enabled boolean DEFAULT false NOT NULL,\n    client_authenticator_type character varying(255),\n    root_url character varying(255),\n    description character varying(255),\n    registration_token character varying(255),\n    standard_flow_enabled boolean DEFAULT true NOT NULL,\n    implicit_flow_enabled boolean DEFAULT false NOT NULL,\n    direct_access_grants_enabled boolean DEFAULT false NOT NULL,\n    always_display_in_console boolean DEFAULT false NOT NULL\n);\n\n\nALTER TABLE public.client OWNER TO keycloak;\n\n--\n-- Name: client_attributes; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client_attributes (\n    client_id character varying(36) NOT NULL,\n    name character varying(255) NOT NULL,\n    value text\n);\n\n\nALTER TABLE public.client_attributes OWNER TO keycloak;\n\n--\n-- Name: client_auth_flow_bindings; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client_auth_flow_bindings (\n    client_id character varying(36) NOT NULL,\n    flow_id character varying(36),\n    binding_name character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.client_auth_flow_bindings OWNER TO keycloak;\n\n--\n-- Name: client_initial_access; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client_initial_access (\n    id character varying(36) NOT NULL,\n    realm_id character varying(36) NOT NULL,\n    \"timestamp\" integer,\n    expiration integer,\n    count integer,\n    remaining_count integer\n);\n\n\nALTER TABLE public.client_initial_access OWNER TO keycloak;\n\n--\n-- Name: client_node_registrations; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client_node_registrations (\n    client_id character varying(36) NOT NULL,\n    value integer,\n    name character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.client_node_registrations OWNER TO keycloak;\n\n--\n-- Name: client_scope; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client_scope (\n    id character varying(36) NOT NULL,\n    name character varying(255),\n    realm_id character varying(36),\n    description character varying(255),\n    protocol character varying(255)\n);\n\n\nALTER TABLE public.client_scope OWNER TO keycloak;\n\n--\n-- Name: client_scope_attributes; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client_scope_attributes (\n    scope_id character varying(36) NOT NULL,\n    value character varying(2048),\n    name character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.client_scope_attributes OWNER TO keycloak;\n\n--\n-- Name: client_scope_client; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client_scope_client (\n    client_id character varying(255) NOT NULL,\n    scope_id character varying(255) NOT NULL,\n    default_scope boolean DEFAULT false NOT NULL\n);\n\n\nALTER TABLE public.client_scope_client OWNER TO keycloak;\n\n--\n-- Name: client_scope_role_mapping; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client_scope_role_mapping (\n    scope_id character varying(36) NOT NULL,\n    role_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.client_scope_role_mapping OWNER TO keycloak;\n\n--\n-- Name: client_session; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client_session (\n    id character varying(36) NOT NULL,\n    client_id character varying(36),\n    redirect_uri character varying(255),\n    state character varying(255),\n    \"timestamp\" integer,\n    session_id character varying(36),\n    auth_method character varying(255),\n    realm_id character varying(255),\n    auth_user_id character varying(36),\n    current_action character varying(36)\n);\n\n\nALTER TABLE public.client_session OWNER TO keycloak;\n\n--\n-- Name: client_session_auth_status; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client_session_auth_status (\n    authenticator character varying(36) NOT NULL,\n    status integer,\n    client_session character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.client_session_auth_status OWNER TO keycloak;\n\n--\n-- Name: client_session_note; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client_session_note (\n    name character varying(255) NOT NULL,\n    value character varying(255),\n    client_session character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.client_session_note OWNER TO keycloak;\n\n--\n-- Name: client_session_prot_mapper; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client_session_prot_mapper (\n    protocol_mapper_id character varying(36) NOT NULL,\n    client_session character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.client_session_prot_mapper OWNER TO keycloak;\n\n--\n-- Name: client_session_role; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client_session_role (\n    role_id character varying(255) NOT NULL,\n    client_session character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.client_session_role OWNER TO keycloak;\n\n--\n-- Name: client_user_session_note; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.client_user_session_note (\n    name character varying(255) NOT NULL,\n    value character varying(2048),\n    client_session character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.client_user_session_note OWNER TO keycloak;\n\n--\n-- Name: component; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.component (\n    id character varying(36) NOT NULL,\n    name character varying(255),\n    parent_id character varying(36),\n    provider_id character varying(36),\n    provider_type character varying(255),\n    realm_id character varying(36),\n    sub_type character varying(255)\n);\n\n\nALTER TABLE public.component OWNER TO keycloak;\n\n--\n-- Name: component_config; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.component_config (\n    id character varying(36) NOT NULL,\n    component_id character varying(36) NOT NULL,\n    name character varying(255) NOT NULL,\n    value character varying(4000)\n);\n\n\nALTER TABLE public.component_config OWNER TO keycloak;\n\n--\n-- Name: composite_role; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.composite_role (\n    composite character varying(36) NOT NULL,\n    child_role character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.composite_role OWNER TO keycloak;\n\n--\n-- Name: credential; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.credential (\n    id character varying(36) NOT NULL,\n    salt bytea,\n    type character varying(255),\n    user_id character varying(36),\n    created_date bigint,\n    user_label character varying(255),\n    secret_data text,\n    credential_data text,\n    priority integer\n);\n\n\nALTER TABLE public.credential OWNER TO keycloak;\n\n--\n-- Name: databasechangelog; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.databasechangelog (\n    id character varying(255) NOT NULL,\n    author character varying(255) NOT NULL,\n    filename character varying(255) NOT NULL,\n    dateexecuted timestamp without time zone NOT NULL,\n    orderexecuted integer NOT NULL,\n    exectype character varying(10) NOT NULL,\n    md5sum character varying(35),\n    description character varying(255),\n    comments character varying(255),\n    tag character varying(255),\n    liquibase character varying(20),\n    contexts character varying(255),\n    labels character varying(255),\n    deployment_id character varying(10)\n);\n\n\nALTER TABLE public.databasechangelog OWNER TO keycloak;\n\n--\n-- Name: databasechangeloglock; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.databasechangeloglock (\n    id integer NOT NULL,\n    locked boolean NOT NULL,\n    lockgranted timestamp without time zone,\n    lockedby character varying(255)\n);\n\n\nALTER TABLE public.databasechangeloglock OWNER TO keycloak;\n\n--\n-- Name: default_client_scope; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.default_client_scope (\n    realm_id character varying(36) NOT NULL,\n    scope_id character varying(36) NOT NULL,\n    default_scope boolean DEFAULT false NOT NULL\n);\n\n\nALTER TABLE public.default_client_scope OWNER TO keycloak;\n\n--\n-- Name: event_entity; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.event_entity (\n    id character varying(36) NOT NULL,\n    client_id character varying(255),\n    details_json character varying(2550),\n    error character varying(255),\n    ip_address character varying(255),\n    realm_id character varying(255),\n    session_id character varying(255),\n    event_time bigint,\n    type character varying(255),\n    user_id character varying(255)\n);\n\n\nALTER TABLE public.event_entity OWNER TO keycloak;\n\n--\n-- Name: fed_user_attribute; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.fed_user_attribute (\n    id character varying(36) NOT NULL,\n    name character varying(255) NOT NULL,\n    user_id character varying(255) NOT NULL,\n    realm_id character varying(36) NOT NULL,\n    storage_provider_id character varying(36),\n    value character varying(2024)\n);\n\n\nALTER TABLE public.fed_user_attribute OWNER TO keycloak;\n\n--\n-- Name: fed_user_consent; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.fed_user_consent (\n    id character varying(36) NOT NULL,\n    client_id character varying(255),\n    user_id character varying(255) NOT NULL,\n    realm_id character varying(36) NOT NULL,\n    storage_provider_id character varying(36),\n    created_date bigint,\n    last_updated_date bigint,\n    client_storage_provider character varying(36),\n    external_client_id character varying(255)\n);\n\n\nALTER TABLE public.fed_user_consent OWNER TO keycloak;\n\n--\n-- Name: fed_user_consent_cl_scope; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.fed_user_consent_cl_scope (\n    user_consent_id character varying(36) NOT NULL,\n    scope_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.fed_user_consent_cl_scope OWNER TO keycloak;\n\n--\n-- Name: fed_user_credential; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.fed_user_credential (\n    id character varying(36) NOT NULL,\n    salt bytea,\n    type character varying(255),\n    created_date bigint,\n    user_id character varying(255) NOT NULL,\n    realm_id character varying(36) NOT NULL,\n    storage_provider_id character varying(36),\n    user_label character varying(255),\n    secret_data text,\n    credential_data text,\n    priority integer\n);\n\n\nALTER TABLE public.fed_user_credential OWNER TO keycloak;\n\n--\n-- Name: fed_user_group_membership; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.fed_user_group_membership (\n    group_id character varying(36) NOT NULL,\n    user_id character varying(255) NOT NULL,\n    realm_id character varying(36) NOT NULL,\n    storage_provider_id character varying(36)\n);\n\n\nALTER TABLE public.fed_user_group_membership OWNER TO keycloak;\n\n--\n-- Name: fed_user_required_action; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.fed_user_required_action (\n    required_action character varying(255) DEFAULT ' '::character varying NOT NULL,\n    user_id character varying(255) NOT NULL,\n    realm_id character varying(36) NOT NULL,\n    storage_provider_id character varying(36)\n);\n\n\nALTER TABLE public.fed_user_required_action OWNER TO keycloak;\n\n--\n-- Name: fed_user_role_mapping; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.fed_user_role_mapping (\n    role_id character varying(36) NOT NULL,\n    user_id character varying(255) NOT NULL,\n    realm_id character varying(36) NOT NULL,\n    storage_provider_id character varying(36)\n);\n\n\nALTER TABLE public.fed_user_role_mapping OWNER TO keycloak;\n\n--\n-- Name: federated_identity; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.federated_identity (\n    identity_provider character varying(255) NOT NULL,\n    realm_id character varying(36),\n    federated_user_id character varying(255),\n    federated_username character varying(255),\n    token text,\n    user_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.federated_identity OWNER TO keycloak;\n\n--\n-- Name: federated_user; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.federated_user (\n    id character varying(255) NOT NULL,\n    storage_provider_id character varying(255),\n    realm_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.federated_user OWNER TO keycloak;\n\n--\n-- Name: group_attribute; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.group_attribute (\n    id character varying(36) DEFAULT 'sybase-needs-something-here'::character varying NOT NULL,\n    name character varying(255) NOT NULL,\n    value character varying(255),\n    group_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.group_attribute OWNER TO keycloak;\n\n--\n-- Name: group_role_mapping; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.group_role_mapping (\n    role_id character varying(36) NOT NULL,\n    group_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.group_role_mapping OWNER TO keycloak;\n\n--\n-- Name: identity_provider; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.identity_provider (\n    internal_id character varying(36) NOT NULL,\n    enabled boolean DEFAULT false NOT NULL,\n    provider_alias character varying(255),\n    provider_id character varying(255),\n    store_token boolean DEFAULT false NOT NULL,\n    authenticate_by_default boolean DEFAULT false NOT NULL,\n    realm_id character varying(36),\n    add_token_role boolean DEFAULT true NOT NULL,\n    trust_email boolean DEFAULT false NOT NULL,\n    first_broker_login_flow_id character varying(36),\n    post_broker_login_flow_id character varying(36),\n    provider_display_name character varying(255),\n    link_only boolean DEFAULT false NOT NULL\n);\n\n\nALTER TABLE public.identity_provider OWNER TO keycloak;\n\n--\n-- Name: identity_provider_config; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.identity_provider_config (\n    identity_provider_id character varying(36) NOT NULL,\n    value text,\n    name character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.identity_provider_config OWNER TO keycloak;\n\n--\n-- Name: identity_provider_mapper; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.identity_provider_mapper (\n    id character varying(36) NOT NULL,\n    name character varying(255) NOT NULL,\n    idp_alias character varying(255) NOT NULL,\n    idp_mapper_name character varying(255) NOT NULL,\n    realm_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.identity_provider_mapper OWNER TO keycloak;\n\n--\n-- Name: idp_mapper_config; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.idp_mapper_config (\n    idp_mapper_id character varying(36) NOT NULL,\n    value text,\n    name character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.idp_mapper_config OWNER TO keycloak;\n\n--\n-- Name: keycloak_group; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.keycloak_group (\n    id character varying(36) NOT NULL,\n    name character varying(255),\n    parent_group character varying(36) NOT NULL,\n    realm_id character varying(36)\n);\n\n\nALTER TABLE public.keycloak_group OWNER TO keycloak;\n\n--\n-- Name: keycloak_role; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.keycloak_role (\n    id character varying(36) NOT NULL,\n    client_realm_constraint character varying(255),\n    client_role boolean DEFAULT false NOT NULL,\n    description character varying(255),\n    name character varying(255),\n    realm_id character varying(255),\n    client character varying(36),\n    realm character varying(36)\n);\n\n\nALTER TABLE public.keycloak_role OWNER TO keycloak;\n\n--\n-- Name: migration_model; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.migration_model (\n    id character varying(36) NOT NULL,\n    version character varying(36),\n    update_time bigint DEFAULT 0 NOT NULL\n);\n\n\nALTER TABLE public.migration_model OWNER TO keycloak;\n\n--\n-- Name: offline_client_session; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.offline_client_session (\n    user_session_id character varying(36) NOT NULL,\n    client_id character varying(255) NOT NULL,\n    offline_flag character varying(4) NOT NULL,\n    \"timestamp\" integer,\n    data text,\n    client_storage_provider character varying(36) DEFAULT 'local'::character varying NOT NULL,\n    external_client_id character varying(255) DEFAULT 'local'::character varying NOT NULL\n);\n\n\nALTER TABLE public.offline_client_session OWNER TO keycloak;\n\n--\n-- Name: offline_user_session; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.offline_user_session (\n    user_session_id character varying(36) NOT NULL,\n    user_id character varying(255) NOT NULL,\n    realm_id character varying(36) NOT NULL,\n    created_on integer NOT NULL,\n    offline_flag character varying(4) NOT NULL,\n    data text,\n    last_session_refresh integer DEFAULT 0 NOT NULL\n);\n\n\nALTER TABLE public.offline_user_session OWNER TO keycloak;\n\n--\n-- Name: policy_config; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.policy_config (\n    policy_id character varying(36) NOT NULL,\n    name character varying(255) NOT NULL,\n    value text\n);\n\n\nALTER TABLE public.policy_config OWNER TO keycloak;\n\n--\n-- Name: protocol_mapper; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.protocol_mapper (\n    id character varying(36) NOT NULL,\n    name character varying(255) NOT NULL,\n    protocol character varying(255) NOT NULL,\n    protocol_mapper_name character varying(255) NOT NULL,\n    client_id character varying(36),\n    client_scope_id character varying(36)\n);\n\n\nALTER TABLE public.protocol_mapper OWNER TO keycloak;\n\n--\n-- Name: protocol_mapper_config; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.protocol_mapper_config (\n    protocol_mapper_id character varying(36) NOT NULL,\n    value text,\n    name character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.protocol_mapper_config OWNER TO keycloak;\n\n--\n-- Name: realm; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.realm (\n    id character varying(36) NOT NULL,\n    access_code_lifespan integer,\n    user_action_lifespan integer,\n    access_token_lifespan integer,\n    account_theme character varying(255),\n    admin_theme character varying(255),\n    email_theme character varying(255),\n    enabled boolean DEFAULT false NOT NULL,\n    events_enabled boolean DEFAULT false NOT NULL,\n    events_expiration bigint,\n    login_theme character varying(255),\n    name character varying(255),\n    not_before integer,\n    password_policy character varying(2550),\n    registration_allowed boolean DEFAULT false NOT NULL,\n    remember_me boolean DEFAULT false NOT NULL,\n    reset_password_allowed boolean DEFAULT false NOT NULL,\n    social boolean DEFAULT false NOT NULL,\n    ssl_required character varying(255),\n    sso_idle_timeout integer,\n    sso_max_lifespan integer,\n    update_profile_on_soc_login boolean DEFAULT false NOT NULL,\n    verify_email boolean DEFAULT false NOT NULL,\n    master_admin_client character varying(36),\n    login_lifespan integer,\n    internationalization_enabled boolean DEFAULT false NOT NULL,\n    default_locale character varying(255),\n    reg_email_as_username boolean DEFAULT false NOT NULL,\n    admin_events_enabled boolean DEFAULT false NOT NULL,\n    admin_events_details_enabled boolean DEFAULT false NOT NULL,\n    edit_username_allowed boolean DEFAULT false NOT NULL,\n    otp_policy_counter integer DEFAULT 0,\n    otp_policy_window integer DEFAULT 1,\n    otp_policy_period integer DEFAULT 30,\n    otp_policy_digits integer DEFAULT 6,\n    otp_policy_alg character varying(36) DEFAULT 'HmacSHA1'::character varying,\n    otp_policy_type character varying(36) DEFAULT 'totp'::character varying,\n    browser_flow character varying(36),\n    registration_flow character varying(36),\n    direct_grant_flow character varying(36),\n    reset_credentials_flow character varying(36),\n    client_auth_flow character varying(36),\n    offline_session_idle_timeout integer DEFAULT 0,\n    revoke_refresh_token boolean DEFAULT false NOT NULL,\n    access_token_life_implicit integer DEFAULT 0,\n    login_with_email_allowed boolean DEFAULT true NOT NULL,\n    duplicate_emails_allowed boolean DEFAULT false NOT NULL,\n    docker_auth_flow character varying(36),\n    refresh_token_max_reuse integer DEFAULT 0,\n    allow_user_managed_access boolean DEFAULT false NOT NULL,\n    sso_max_lifespan_remember_me integer DEFAULT 0 NOT NULL,\n    sso_idle_timeout_remember_me integer DEFAULT 0 NOT NULL,\n    default_role character varying(255)\n);\n\n\nALTER TABLE public.realm OWNER TO keycloak;\n\n--\n-- Name: realm_attribute; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.realm_attribute (\n    name character varying(255) NOT NULL,\n    realm_id character varying(36) NOT NULL,\n    value text\n);\n\n\nALTER TABLE public.realm_attribute OWNER TO keycloak;\n\n--\n-- Name: realm_default_groups; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.realm_default_groups (\n    realm_id character varying(36) NOT NULL,\n    group_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.realm_default_groups OWNER TO keycloak;\n\n--\n-- Name: realm_enabled_event_types; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.realm_enabled_event_types (\n    realm_id character varying(36) NOT NULL,\n    value character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.realm_enabled_event_types OWNER TO keycloak;\n\n--\n-- Name: realm_events_listeners; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.realm_events_listeners (\n    realm_id character varying(36) NOT NULL,\n    value character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.realm_events_listeners OWNER TO keycloak;\n\n--\n-- Name: realm_localizations; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.realm_localizations (\n    realm_id character varying(255) NOT NULL,\n    locale character varying(255) NOT NULL,\n    texts text NOT NULL\n);\n\n\nALTER TABLE public.realm_localizations OWNER TO keycloak;\n\n--\n-- Name: realm_required_credential; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.realm_required_credential (\n    type character varying(255) NOT NULL,\n    form_label character varying(255),\n    input boolean DEFAULT false NOT NULL,\n    secret boolean DEFAULT false NOT NULL,\n    realm_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.realm_required_credential OWNER TO keycloak;\n\n--\n-- Name: realm_smtp_config; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.realm_smtp_config (\n    realm_id character varying(36) NOT NULL,\n    value character varying(255),\n    name character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.realm_smtp_config OWNER TO keycloak;\n\n--\n-- Name: realm_supported_locales; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.realm_supported_locales (\n    realm_id character varying(36) NOT NULL,\n    value character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.realm_supported_locales OWNER TO keycloak;\n\n--\n-- Name: redirect_uris; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.redirect_uris (\n    client_id character varying(36) NOT NULL,\n    value character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.redirect_uris OWNER TO keycloak;\n\n--\n-- Name: required_action_config; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.required_action_config (\n    required_action_id character varying(36) NOT NULL,\n    value text,\n    name character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.required_action_config OWNER TO keycloak;\n\n--\n-- Name: required_action_provider; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.required_action_provider (\n    id character varying(36) NOT NULL,\n    alias character varying(255),\n    name character varying(255),\n    realm_id character varying(36),\n    enabled boolean DEFAULT false NOT NULL,\n    default_action boolean DEFAULT false NOT NULL,\n    provider_id character varying(255),\n    priority integer\n);\n\n\nALTER TABLE public.required_action_provider OWNER TO keycloak;\n\n--\n-- Name: resource_attribute; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.resource_attribute (\n    id character varying(36) DEFAULT 'sybase-needs-something-here'::character varying NOT NULL,\n    name character varying(255) NOT NULL,\n    value character varying(255),\n    resource_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.resource_attribute OWNER TO keycloak;\n\n--\n-- Name: resource_policy; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.resource_policy (\n    resource_id character varying(36) NOT NULL,\n    policy_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.resource_policy OWNER TO keycloak;\n\n--\n-- Name: resource_scope; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.resource_scope (\n    resource_id character varying(36) NOT NULL,\n    scope_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.resource_scope OWNER TO keycloak;\n\n--\n-- Name: resource_server; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.resource_server (\n    id character varying(36) NOT NULL,\n    allow_rs_remote_mgmt boolean DEFAULT false NOT NULL,\n    policy_enforce_mode smallint NOT NULL,\n    decision_strategy smallint DEFAULT 1 NOT NULL\n);\n\n\nALTER TABLE public.resource_server OWNER TO keycloak;\n\n--\n-- Name: resource_server_perm_ticket; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.resource_server_perm_ticket (\n    id character varying(36) NOT NULL,\n    owner character varying(255) NOT NULL,\n    requester character varying(255) NOT NULL,\n    created_timestamp bigint NOT NULL,\n    granted_timestamp bigint,\n    resource_id character varying(36) NOT NULL,\n    scope_id character varying(36),\n    resource_server_id character varying(36) NOT NULL,\n    policy_id character varying(36)\n);\n\n\nALTER TABLE public.resource_server_perm_ticket OWNER TO keycloak;\n\n--\n-- Name: resource_server_policy; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.resource_server_policy (\n    id character varying(36) NOT NULL,\n    name character varying(255) NOT NULL,\n    description character varying(255),\n    type character varying(255) NOT NULL,\n    decision_strategy smallint,\n    logic smallint,\n    resource_server_id character varying(36) NOT NULL,\n    owner character varying(255)\n);\n\n\nALTER TABLE public.resource_server_policy OWNER TO keycloak;\n\n--\n-- Name: resource_server_resource; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.resource_server_resource (\n    id character varying(36) NOT NULL,\n    name character varying(255) NOT NULL,\n    type character varying(255),\n    icon_uri character varying(255),\n    owner character varying(255) NOT NULL,\n    resource_server_id character varying(36) NOT NULL,\n    owner_managed_access boolean DEFAULT false NOT NULL,\n    display_name character varying(255)\n);\n\n\nALTER TABLE public.resource_server_resource OWNER TO keycloak;\n\n--\n-- Name: resource_server_scope; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.resource_server_scope (\n    id character varying(36) NOT NULL,\n    name character varying(255) NOT NULL,\n    icon_uri character varying(255),\n    resource_server_id character varying(36) NOT NULL,\n    display_name character varying(255)\n);\n\n\nALTER TABLE public.resource_server_scope OWNER TO keycloak;\n\n--\n-- Name: resource_uris; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.resource_uris (\n    resource_id character varying(36) NOT NULL,\n    value character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.resource_uris OWNER TO keycloak;\n\n--\n-- Name: role_attribute; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.role_attribute (\n    id character varying(36) NOT NULL,\n    role_id character varying(36) NOT NULL,\n    name character varying(255) NOT NULL,\n    value character varying(255)\n);\n\n\nALTER TABLE public.role_attribute OWNER TO keycloak;\n\n--\n-- Name: scope_mapping; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.scope_mapping (\n    client_id character varying(36) NOT NULL,\n    role_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.scope_mapping OWNER TO keycloak;\n\n--\n-- Name: scope_policy; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.scope_policy (\n    scope_id character varying(36) NOT NULL,\n    policy_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.scope_policy OWNER TO keycloak;\n\n--\n-- Name: user_attribute; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.user_attribute (\n    name character varying(255) NOT NULL,\n    value character varying(255),\n    user_id character varying(36) NOT NULL,\n    id character varying(36) DEFAULT 'sybase-needs-something-here'::character varying NOT NULL\n);\n\n\nALTER TABLE public.user_attribute OWNER TO keycloak;\n\n--\n-- Name: user_consent; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.user_consent (\n    id character varying(36) NOT NULL,\n    client_id character varying(255),\n    user_id character varying(36) NOT NULL,\n    created_date bigint,\n    last_updated_date bigint,\n    client_storage_provider character varying(36),\n    external_client_id character varying(255)\n);\n\n\nALTER TABLE public.user_consent OWNER TO keycloak;\n\n--\n-- Name: user_consent_client_scope; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.user_consent_client_scope (\n    user_consent_id character varying(36) NOT NULL,\n    scope_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.user_consent_client_scope OWNER TO keycloak;\n\n--\n-- Name: user_entity; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.user_entity (\n    id character varying(36) NOT NULL,\n    email character varying(255),\n    email_constraint character varying(255),\n    email_verified boolean DEFAULT false NOT NULL,\n    enabled boolean DEFAULT false NOT NULL,\n    federation_link character varying(255),\n    first_name character varying(255),\n    last_name character varying(255),\n    realm_id character varying(255),\n    username character varying(255),\n    created_timestamp bigint,\n    service_account_client_link character varying(255),\n    not_before integer DEFAULT 0 NOT NULL\n);\n\n\nALTER TABLE public.user_entity OWNER TO keycloak;\n\n--\n-- Name: user_federation_config; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.user_federation_config (\n    user_federation_provider_id character varying(36) NOT NULL,\n    value character varying(255),\n    name character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.user_federation_config OWNER TO keycloak;\n\n--\n-- Name: user_federation_mapper; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.user_federation_mapper (\n    id character varying(36) NOT NULL,\n    name character varying(255) NOT NULL,\n    federation_provider_id character varying(36) NOT NULL,\n    federation_mapper_type character varying(255) NOT NULL,\n    realm_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.user_federation_mapper OWNER TO keycloak;\n\n--\n-- Name: user_federation_mapper_config; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.user_federation_mapper_config (\n    user_federation_mapper_id character varying(36) NOT NULL,\n    value character varying(255),\n    name character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.user_federation_mapper_config OWNER TO keycloak;\n\n--\n-- Name: user_federation_provider; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.user_federation_provider (\n    id character varying(36) NOT NULL,\n    changed_sync_period integer,\n    display_name character varying(255),\n    full_sync_period integer,\n    last_sync integer,\n    priority integer,\n    provider_name character varying(255),\n    realm_id character varying(36)\n);\n\n\nALTER TABLE public.user_federation_provider OWNER TO keycloak;\n\n--\n-- Name: user_group_membership; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.user_group_membership (\n    group_id character varying(36) NOT NULL,\n    user_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.user_group_membership OWNER TO keycloak;\n\n--\n-- Name: user_required_action; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.user_required_action (\n    user_id character varying(36) NOT NULL,\n    required_action character varying(255) DEFAULT ' '::character varying NOT NULL\n);\n\n\nALTER TABLE public.user_required_action OWNER TO keycloak;\n\n--\n-- Name: user_role_mapping; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.user_role_mapping (\n    role_id character varying(255) NOT NULL,\n    user_id character varying(36) NOT NULL\n);\n\n\nALTER TABLE public.user_role_mapping OWNER TO keycloak;\n\n--\n-- Name: user_session; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.user_session (\n    id character varying(36) NOT NULL,\n    auth_method character varying(255),\n    ip_address character varying(255),\n    last_session_refresh integer,\n    login_username character varying(255),\n    realm_id character varying(255),\n    remember_me boolean DEFAULT false NOT NULL,\n    started integer,\n    user_id character varying(255),\n    user_session_state integer,\n    broker_session_id character varying(255),\n    broker_user_id character varying(255)\n);\n\n\nALTER TABLE public.user_session OWNER TO keycloak;\n\n--\n-- Name: user_session_note; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.user_session_note (\n    user_session character varying(36) NOT NULL,\n    name character varying(255) NOT NULL,\n    value character varying(2048)\n);\n\n\nALTER TABLE public.user_session_note OWNER TO keycloak;\n\n--\n-- Name: username_login_failure; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.username_login_failure (\n    realm_id character varying(36) NOT NULL,\n    username character varying(255) NOT NULL,\n    failed_login_not_before integer,\n    last_failure bigint,\n    last_ip_failure character varying(255),\n    num_failures integer\n);\n\n\nALTER TABLE public.username_login_failure OWNER TO keycloak;\n\n--\n-- Name: web_origins; Type: TABLE; Schema: public; Owner: keycloak\n--\n\nCREATE TABLE public.web_origins (\n    client_id character varying(36) NOT NULL,\n    value character varying(255) NOT NULL\n);\n\n\nALTER TABLE public.web_origins OWNER TO keycloak;\n\n--\n-- Data for Name: admin_event_entity; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.admin_event_entity (id, admin_event_time, realm_id, operation_type, auth_realm_id, auth_client_id, auth_user_id, ip_address, resource_path, representation, error, resource_type) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: associated_policy; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.associated_policy (policy_id, associated_policy_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: authentication_execution; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.authentication_execution (id, alias, authenticator, realm_id, flow_id, requirement, priority, authenticator_flow, auth_flow_id, auth_config) FROM stdin;\n8772f340-fd75-43e9-8086-246e6db08945\t\\N\tauth-cookie\tc4049252-49df-41a9-aef7-48d83ef55b9b\t159bae2e-081c-47d5-93fd-7af7e429eaa4\t2\t10\tf\t\\N\t\\N\n6125919c-671f-42f3-a35b-012d33c4a569\t\\N\tauth-spnego\tc4049252-49df-41a9-aef7-48d83ef55b9b\t159bae2e-081c-47d5-93fd-7af7e429eaa4\t3\t20\tf\t\\N\t\\N\nb14c8744-cc15-4830-aaec-a30747f7af6f\t\\N\tidentity-provider-redirector\tc4049252-49df-41a9-aef7-48d83ef55b9b\t159bae2e-081c-47d5-93fd-7af7e429eaa4\t2\t25\tf\t\\N\t\\N\n62f93862-19b1-41d2-8fec-0ff7119065fd\t\\N\t\\N\tc4049252-49df-41a9-aef7-48d83ef55b9b\t159bae2e-081c-47d5-93fd-7af7e429eaa4\t2\t30\tt\t5e199291-461c-4608-9a0c-785eac119f2d\t\\N\n72a7111c-dd17-40cd-91e4-0dea4e58c3d3\t\\N\tauth-username-password-form\tc4049252-49df-41a9-aef7-48d83ef55b9b\t5e199291-461c-4608-9a0c-785eac119f2d\t0\t10\tf\t\\N\t\\N\n452a7342-e01d-4eff-a24e-1dd440839a19\t\\N\t\\N\tc4049252-49df-41a9-aef7-48d83ef55b9b\t5e199291-461c-4608-9a0c-785eac119f2d\t1\t20\tt\t2ac20c91-8414-4e79-80e4-1f5c68df3f15\t\\N\n637fd1c1-e0c4-4974-b7a2-a9835e51f161\t\\N\tconditional-user-configured\tc4049252-49df-41a9-aef7-48d83ef55b9b\t2ac20c91-8414-4e79-80e4-1f5c68df3f15\t0\t10\tf\t\\N\t\\N\n50e2717b-11df-488c-b8be-066b9bfb8418\t\\N\tauth-otp-form\tc4049252-49df-41a9-aef7-48d83ef55b9b\t2ac20c91-8414-4e79-80e4-1f5c68df3f15\t0\t20\tf\t\\N\t\\N\n2b3b47a3-92aa-4241-9625-c5680ff53602\t\\N\tdirect-grant-validate-username\tc4049252-49df-41a9-aef7-48d83ef55b9b\t42be49df-6d7d-466d-8de1-61670080ea13\t0\t10\tf\t\\N\t\\N\n53722e76-7d66-4e25-9725-ca263e764838\t\\N\tdirect-grant-validate-password\tc4049252-49df-41a9-aef7-48d83ef55b9b\t42be49df-6d7d-466d-8de1-61670080ea13\t0\t20\tf\t\\N\t\\N\n9e960844-418c-4055-862b-53a327e741f6\t\\N\t\\N\tc4049252-49df-41a9-aef7-48d83ef55b9b\t42be49df-6d7d-466d-8de1-61670080ea13\t1\t30\tt\t8cdc2fb8-0299-46a4-8a3c-0e90c7518420\t\\N\nd1eaa8e7-32de-479c-a8d6-2e3ed8d07cf1\t\\N\tconditional-user-configured\tc4049252-49df-41a9-aef7-48d83ef55b9b\t8cdc2fb8-0299-46a4-8a3c-0e90c7518420\t0\t10\tf\t\\N\t\\N\n4ef72219-3ec2-418c-9bc7-50a994b5995a\t\\N\tdirect-grant-validate-otp\tc4049252-49df-41a9-aef7-48d83ef55b9b\t8cdc2fb8-0299-46a4-8a3c-0e90c7518420\t0\t20\tf\t\\N\t\\N\ncde126d7-bb79-4173-ba72-f8d41fde1b0f\t\\N\tregistration-page-form\tc4049252-49df-41a9-aef7-48d83ef55b9b\tb471a32c-a016-4141-89d8-3d8d0858a459\t0\t10\tt\t7573f1c0-4fe2-451c-832d-05a14d5277ac\t\\N\n292c3f4b-1a0a-48a9-af09-f76e3511362d\t\\N\tregistration-user-creation\tc4049252-49df-41a9-aef7-48d83ef55b9b\t7573f1c0-4fe2-451c-832d-05a14d5277ac\t0\t20\tf\t\\N\t\\N\n243d1bb1-33a0-41c1-b49e-01b86e433f32\t\\N\tregistration-profile-action\tc4049252-49df-41a9-aef7-48d83ef55b9b\t7573f1c0-4fe2-451c-832d-05a14d5277ac\t0\t40\tf\t\\N\t\\N\n515aa4b1-576b-4930-adcf-97ef0ad904f1\t\\N\tregistration-password-action\tc4049252-49df-41a9-aef7-48d83ef55b9b\t7573f1c0-4fe2-451c-832d-05a14d5277ac\t0\t50\tf\t\\N\t\\N\nc0d91713-6968-431e-8b02-dbedec9fa3fc\t\\N\tregistration-recaptcha-action\tc4049252-49df-41a9-aef7-48d83ef55b9b\t7573f1c0-4fe2-451c-832d-05a14d5277ac\t3\t60\tf\t\\N\t\\N\n25c1f362-6f4b-4824-8464-70e0da543720\t\\N\tregistration-terms-and-conditions\tc4049252-49df-41a9-aef7-48d83ef55b9b\t7573f1c0-4fe2-451c-832d-05a14d5277ac\t3\t70\tf\t\\N\t\\N\nae799299-ce7c-496b-8645-725d4d76fd2d\t\\N\treset-credentials-choose-user\tc4049252-49df-41a9-aef7-48d83ef55b9b\t823e4454-4915-44cd-8e1f-d2fca1cfbc0d\t0\t10\tf\t\\N\t\\N\n8456c60e-d933-4996-86c4-e64ae5f788d0\t\\N\treset-credential-email\tc4049252-49df-41a9-aef7-48d83ef55b9b\t823e4454-4915-44cd-8e1f-d2fca1cfbc0d\t0\t20\tf\t\\N\t\\N\n47e51aca-52a9-40c0-82e1-4102c6157460\t\\N\treset-password\tc4049252-49df-41a9-aef7-48d83ef55b9b\t823e4454-4915-44cd-8e1f-d2fca1cfbc0d\t0\t30\tf\t\\N\t\\N\nfee402e1-1e8e-4e6f-940f-25f57804e348\t\\N\t\\N\tc4049252-49df-41a9-aef7-48d83ef55b9b\t823e4454-4915-44cd-8e1f-d2fca1cfbc0d\t1\t40\tt\t7713625d-34c4-4d24-8c31-eeb487098c53\t\\N\nf6b23d93-d452-491c-9bed-7eb5c2d72b26\t\\N\tconditional-user-configured\tc4049252-49df-41a9-aef7-48d83ef55b9b\t7713625d-34c4-4d24-8c31-eeb487098c53\t0\t10\tf\t\\N\t\\N\n7fa4e2ec-0be3-4985-a74b-c969a845caad\t\\N\treset-otp\tc4049252-49df-41a9-aef7-48d83ef55b9b\t7713625d-34c4-4d24-8c31-eeb487098c53\t0\t20\tf\t\\N\t\\N\n4ccdd3ed-f1ce-4e77-b608-7aa2272c0c75\t\\N\tclient-secret\tc4049252-49df-41a9-aef7-48d83ef55b9b\t91b46ef4-7403-4802-8554-63f87060ffe7\t2\t10\tf\t\\N\t\\N\n805edf61-4e61-4bd2-bf48-fcb64a91c1e4\t\\N\tclient-jwt\tc4049252-49df-41a9-aef7-48d83ef55b9b\t91b46ef4-7403-4802-8554-63f87060ffe7\t2\t20\tf\t\\N\t\\N\ncfa2d1df-ccb4-4180-a0cb-3988af46a4ee\t\\N\tclient-secret-jwt\tc4049252-49df-41a9-aef7-48d83ef55b9b\t91b46ef4-7403-4802-8554-63f87060ffe7\t2\t30\tf\t\\N\t\\N\nf09721bf-529d-44b7-ae5e-158974e84ac5\t\\N\tclient-x509\tc4049252-49df-41a9-aef7-48d83ef55b9b\t91b46ef4-7403-4802-8554-63f87060ffe7\t2\t40\tf\t\\N\t\\N\n54b702d2-a168-4453-b4f8-a190b05fac25\t\\N\tidp-review-profile\tc4049252-49df-41a9-aef7-48d83ef55b9b\ta869a3b3-4cb4-4e68-bfaa-952a531cf721\t0\t10\tf\t\\N\t42d71774-ece2-4d90-9768-7b86133e639c\n48d30e9a-78de-4c22-a481-c970f333398c\t\\N\t\\N\tc4049252-49df-41a9-aef7-48d83ef55b9b\ta869a3b3-4cb4-4e68-bfaa-952a531cf721\t0\t20\tt\t3c0f6327-fe8f-4824-9776-ac9251f17e5c\t\\N\n465ec204-99c3-4811-a6c2-832264c6f67c\t\\N\tidp-create-user-if-unique\tc4049252-49df-41a9-aef7-48d83ef55b9b\t3c0f6327-fe8f-4824-9776-ac9251f17e5c\t2\t10\tf\t\\N\t6a6dfcc6-3608-4c81-ba15-6ea35ec3cd31\n51ce522b-8b57-41bc-a717-ca6839bbfe26\t\\N\t\\N\tc4049252-49df-41a9-aef7-48d83ef55b9b\t3c0f6327-fe8f-4824-9776-ac9251f17e5c\t2\t20\tt\t1abccf67-8880-41c7-a28b-07297186812c\t\\N\n2180d6ba-c62c-4225-ba8e-30e14c521be1\t\\N\tidp-confirm-link\tc4049252-49df-41a9-aef7-48d83ef55b9b\t1abccf67-8880-41c7-a28b-07297186812c\t0\t10\tf\t\\N\t\\N\n51e75bdb-6184-45d9-b5cb-5b052fbcdb5d\t\\N\t\\N\tc4049252-49df-41a9-aef7-48d83ef55b9b\t1abccf67-8880-41c7-a28b-07297186812c\t0\t20\tt\tdf318a22-333e-4170-a1c6-d5a0e0a0a9d0\t\\N\na714169c-8a8a-413c-bb2e-3458334aedb3\t\\N\tidp-email-verification\tc4049252-49df-41a9-aef7-48d83ef55b9b\tdf318a22-333e-4170-a1c6-d5a0e0a0a9d0\t2\t10\tf\t\\N\t\\N\ncba211bc-c8ba-4d3c-9526-4a1a06b761f1\t\\N\t\\N\tc4049252-49df-41a9-aef7-48d83ef55b9b\tdf318a22-333e-4170-a1c6-d5a0e0a0a9d0\t2\t20\tt\t4749c964-7a2c-40c3-94b5-e690d9557a73\t\\N\ne06d7c15-cb8b-4daa-a39c-c2084fc870c8\t\\N\tidp-username-password-form\tc4049252-49df-41a9-aef7-48d83ef55b9b\t4749c964-7a2c-40c3-94b5-e690d9557a73\t0\t10\tf\t\\N\t\\N\naaf556b8-c1ee-49fa-838f-06aa7b800607\t\\N\t\\N\tc4049252-49df-41a9-aef7-48d83ef55b9b\t4749c964-7a2c-40c3-94b5-e690d9557a73\t1\t20\tt\t1a20d932-8ceb-4ced-ad5a-a55a0c45ee55\t\\N\n63c75772-8b45-4e9b-ac96-8a1aa94efa34\t\\N\tconditional-user-configured\tc4049252-49df-41a9-aef7-48d83ef55b9b\t1a20d932-8ceb-4ced-ad5a-a55a0c45ee55\t0\t10\tf\t\\N\t\\N\n5c4dc3bb-f601-4e57-aa9e-8afe4430810b\t\\N\tauth-otp-form\tc4049252-49df-41a9-aef7-48d83ef55b9b\t1a20d932-8ceb-4ced-ad5a-a55a0c45ee55\t0\t20\tf\t\\N\t\\N\naf1c2d2a-6ec7-4fbc-9de1-3459442ff277\t\\N\thttp-basic-authenticator\tc4049252-49df-41a9-aef7-48d83ef55b9b\te2b6b838-c360-4509-82c5-855c2ab4ed75\t0\t10\tf\t\\N\t\\N\n719d775d-f364-4694-97ee-d4da74d6f241\t\\N\tdocker-http-basic-authenticator\tc4049252-49df-41a9-aef7-48d83ef55b9b\t8360020f-cfaf-4fcc-8c00-6be8dc2edb68\t0\t10\tf\t\\N\t\\N\n9ac73aac-6405-481b-9662-982aea46ac6f\t\\N\tauth-cookie\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t471b9257-3796-4a4a-88b5-84d03dc770b6\t2\t10\tf\t\\N\t\\N\nbd230181-e953-4506-9ed1-7c4c1221c0f8\t\\N\tauth-spnego\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t471b9257-3796-4a4a-88b5-84d03dc770b6\t3\t20\tf\t\\N\t\\N\nd3d9d008-ce28-4197-8e9f-665ada9e3bc2\t\\N\tidentity-provider-redirector\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t471b9257-3796-4a4a-88b5-84d03dc770b6\t2\t25\tf\t\\N\t\\N\n64ddeb06-9cd0-4a9b-be68-5656fa807441\t\\N\t\\N\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t471b9257-3796-4a4a-88b5-84d03dc770b6\t2\t30\tt\t1334bf46-99c7-4456-92b1-f9e46530c974\t\\N\nce0d4840-c7dc-422c-9d4d-c35e30cd91ae\t\\N\tauth-username-password-form\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t1334bf46-99c7-4456-92b1-f9e46530c974\t0\t10\tf\t\\N\t\\N\ne294fbfe-c0bd-4298-9953-93a43b0f125a\t\\N\t\\N\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t1334bf46-99c7-4456-92b1-f9e46530c974\t1\t20\tt\t163a45e7-8377-417a-8f97-7b2f420e53e3\t\\N\n955c0535-4853-4dbf-900c-1b12c6a0199b\t\\N\tconditional-user-configured\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t163a45e7-8377-417a-8f97-7b2f420e53e3\t0\t10\tf\t\\N\t\\N\nc71c0b81-289f-410d-b6ee-0e7a1a0b2028\t\\N\tauth-otp-form\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t163a45e7-8377-417a-8f97-7b2f420e53e3\t0\t20\tf\t\\N\t\\N\n5ee7fa0b-8a74-489a-af50-abca6e4a14a0\t\\N\tdirect-grant-validate-username\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t9117851c-72ce-484f-9056-02c546f2af03\t0\t10\tf\t\\N\t\\N\n1d5734bb-39a6-4748-a9cf-1b8271eee73f\t\\N\tdirect-grant-validate-password\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t9117851c-72ce-484f-9056-02c546f2af03\t0\t20\tf\t\\N\t\\N\n15add8f7-72c5-4170-b9a8-898382f6e821\t\\N\t\\N\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t9117851c-72ce-484f-9056-02c546f2af03\t1\t30\tt\t84010cb8-1db6-4c4b-b93d-0cec9ab889da\t\\N\nf456d57d-08a7-44cb-83d3-4a49185315c8\t\\N\tconditional-user-configured\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t84010cb8-1db6-4c4b-b93d-0cec9ab889da\t0\t10\tf\t\\N\t\\N\nced6bfd9-f468-4012-8cde-d5498c2141a5\t\\N\tdirect-grant-validate-otp\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t84010cb8-1db6-4c4b-b93d-0cec9ab889da\t0\t20\tf\t\\N\t\\N\nbc4e0764-96b2-4f82-bbd2-60651e83cf98\t\\N\tregistration-page-form\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t994ce356-a898-4e82-9be8-f0b19ba4f3e6\t0\t10\tt\tea5719b7-f2d1-4c09-bc04-d768d2168362\t\\N\n9a2ff553-f1ee-42db-87c2-1937469576f1\t\\N\tregistration-user-creation\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tea5719b7-f2d1-4c09-bc04-d768d2168362\t0\t20\tf\t\\N\t\\N\n4de46091-f4f9-4717-bf89-fbc3e234d426\t\\N\tregistration-profile-action\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tea5719b7-f2d1-4c09-bc04-d768d2168362\t0\t40\tf\t\\N\t\\N\ne376930d-4c96-48f7-9774-3ef07b099dd0\t\\N\tregistration-password-action\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tea5719b7-f2d1-4c09-bc04-d768d2168362\t0\t50\tf\t\\N\t\\N\nad32216f-4f7e-4586-9440-dac1fecd72d1\t\\N\tregistration-recaptcha-action\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tea5719b7-f2d1-4c09-bc04-d768d2168362\t3\t60\tf\t\\N\t\\N\n00694167-a808-4573-8a9b-751fcf28693c\t\\N\treset-credentials-choose-user\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tc9812133-8011-408d-bc85-9857e0964b9f\t0\t10\tf\t\\N\t\\N\n2b9ae7fd-b01e-4110-9daa-056543fddc56\t\\N\treset-credential-email\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tc9812133-8011-408d-bc85-9857e0964b9f\t0\t20\tf\t\\N\t\\N\ne726eccf-dc66-4461-ac1d-861ea1f60e93\t\\N\treset-password\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tc9812133-8011-408d-bc85-9857e0964b9f\t0\t30\tf\t\\N\t\\N\n8eb143cd-88f5-4350-8d32-b9d2a1c448b5\t\\N\t\\N\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tc9812133-8011-408d-bc85-9857e0964b9f\t1\t40\tt\t50ddcefb-d2bf-4fed-9b51-88e91f438584\t\\N\nbc5f1c89-aab0-4676-857c-4fd23e9a1e1c\t\\N\tconditional-user-configured\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t50ddcefb-d2bf-4fed-9b51-88e91f438584\t0\t10\tf\t\\N\t\\N\nb99c7aa3-24a9-49cb-84d8-dbf776541ec6\t\\N\treset-otp\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t50ddcefb-d2bf-4fed-9b51-88e91f438584\t0\t20\tf\t\\N\t\\N\n74a7bfa8-658a-4967-97ab-642bf7687afc\t\\N\tclient-secret\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t906fb94d-9f4d-490b-8c43-f29a304b9bed\t2\t10\tf\t\\N\t\\N\nbf6c6b87-a4ab-4e08-a4cc-12f81492ac21\t\\N\tclient-jwt\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t906fb94d-9f4d-490b-8c43-f29a304b9bed\t2\t20\tf\t\\N\t\\N\n61fc8b96-73d4-4d0a-b8c5-8e1507cbb67c\t\\N\tclient-secret-jwt\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t906fb94d-9f4d-490b-8c43-f29a304b9bed\t2\t30\tf\t\\N\t\\N\n5335801b-bd66-49ad-8351-2a2ee8727bb0\t\\N\tclient-x509\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t906fb94d-9f4d-490b-8c43-f29a304b9bed\t2\t40\tf\t\\N\t\\N\n761b5a15-8845-47db-87fc-b1385d5b8a88\t\\N\tidp-review-profile\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t710158d0-879b-44b1-a065-f6429712db02\t0\t10\tf\t\\N\t75c4e6c1-7d58-435f-bb8e-cd18c5183180\nbafff1df-4353-42fe-93c6-6a416b1586f1\t\\N\t\\N\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t710158d0-879b-44b1-a065-f6429712db02\t0\t20\tt\tbd6a8301-eba6-4c64-8130-7fcbe6a67ec6\t\\N\n52f1eb72-8a04-43a8-8bee-c1c0f88834f0\t\\N\tidp-create-user-if-unique\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbd6a8301-eba6-4c64-8130-7fcbe6a67ec6\t2\t10\tf\t\\N\t6674de47-71e7-43dd-9523-c9ee1fcdc3bb\nacd425d5-2436-44eb-b91d-3e482737e7e0\t\\N\t\\N\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbd6a8301-eba6-4c64-8130-7fcbe6a67ec6\t2\t20\tt\t6d8ebb24-d795-46de-bb64-3bbe488bffce\t\\N\n0313690f-fb9f-4b56-b484-16c79abdc9b1\t\\N\tidp-confirm-link\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t6d8ebb24-d795-46de-bb64-3bbe488bffce\t0\t10\tf\t\\N\t\\N\n32a53c12-ee12-4a49-aeb5-847fd2f4988f\t\\N\t\\N\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t6d8ebb24-d795-46de-bb64-3bbe488bffce\t0\t20\tt\t594e65ee-5d11-430f-8249-d3ecab6735ad\t\\N\n9919a0c7-da07-4eb2-b396-ba3dd97dddcd\t\\N\tidp-email-verification\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t594e65ee-5d11-430f-8249-d3ecab6735ad\t2\t10\tf\t\\N\t\\N\nab774de2-9d51-47b0-8735-58e9716113fa\t\\N\t\\N\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t594e65ee-5d11-430f-8249-d3ecab6735ad\t2\t20\tt\t33ca1bb1-e14a-44c5-a0c1-b3fd94db0ff3\t\\N\n446a207e-9bdd-4814-a126-56cf59fa4557\t\\N\tidp-username-password-form\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t33ca1bb1-e14a-44c5-a0c1-b3fd94db0ff3\t0\t10\tf\t\\N\t\\N\nbb46f984-1470-4c3d-a480-1e5cd59efce6\t\\N\t\\N\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t33ca1bb1-e14a-44c5-a0c1-b3fd94db0ff3\t1\t20\tt\t035a0e9e-0005-40dc-b8f1-3cd8f92ceb7e\t\\N\n62d69b3e-2f22-4814-ba18-b37010a7527e\t\\N\tconditional-user-configured\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t035a0e9e-0005-40dc-b8f1-3cd8f92ceb7e\t0\t10\tf\t\\N\t\\N\nd3ba8c1d-d0d9-4b55-a8e1-9ddf99423eb0\t\\N\tauth-otp-form\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t035a0e9e-0005-40dc-b8f1-3cd8f92ceb7e\t0\t20\tf\t\\N\t\\N\n78b6261a-6613-4816-aaeb-a95dd8d502fc\t\\N\thttp-basic-authenticator\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t57ee353e-d235-4a46-97fa-5d86f5b31faf\t0\t10\tf\t\\N\t\\N\ndbb639f4-8d6d-4f01-b962-0ce801a87cd3\t\\N\tdocker-http-basic-authenticator\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tff236ccc-385f-4d41-b325-95de0d50bfd5\t0\t10\tf\t\\N\t\\N\n\\.\n\n\n--\n-- Data for Name: authentication_flow; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.authentication_flow (id, alias, description, realm_id, provider_id, top_level, built_in) FROM stdin;\n159bae2e-081c-47d5-93fd-7af7e429eaa4\tbrowser\tbrowser based authentication\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tt\tt\n5e199291-461c-4608-9a0c-785eac119f2d\tforms\tUsername, password, otp and other auth forms.\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tf\tt\n2ac20c91-8414-4e79-80e4-1f5c68df3f15\tBrowser - Conditional OTP\tFlow to determine if the OTP is required for the authentication\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tf\tt\n42be49df-6d7d-466d-8de1-61670080ea13\tdirect grant\tOpenID Connect Resource Owner Grant\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tt\tt\n8cdc2fb8-0299-46a4-8a3c-0e90c7518420\tDirect Grant - Conditional OTP\tFlow to determine if the OTP is required for the authentication\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tf\tt\nb471a32c-a016-4141-89d8-3d8d0858a459\tregistration\tregistration flow\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tt\tt\n7573f1c0-4fe2-451c-832d-05a14d5277ac\tregistration form\tregistration form\tc4049252-49df-41a9-aef7-48d83ef55b9b\tform-flow\tf\tt\n823e4454-4915-44cd-8e1f-d2fca1cfbc0d\treset credentials\tReset credentials for a user if they forgot their password or something\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tt\tt\n7713625d-34c4-4d24-8c31-eeb487098c53\tReset - Conditional OTP\tFlow to determine if the OTP should be reset or not. Set to REQUIRED to force.\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tf\tt\n91b46ef4-7403-4802-8554-63f87060ffe7\tclients\tBase authentication for clients\tc4049252-49df-41a9-aef7-48d83ef55b9b\tclient-flow\tt\tt\na869a3b3-4cb4-4e68-bfaa-952a531cf721\tfirst broker login\tActions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tt\tt\n3c0f6327-fe8f-4824-9776-ac9251f17e5c\tUser creation or linking\tFlow for the existing/non-existing user alternatives\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tf\tt\n1abccf67-8880-41c7-a28b-07297186812c\tHandle Existing Account\tHandle what to do if there is existing account with same email/username like authenticated identity provider\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tf\tt\ndf318a22-333e-4170-a1c6-d5a0e0a0a9d0\tAccount verification options\tMethod with which to verity the existing account\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tf\tt\n4749c964-7a2c-40c3-94b5-e690d9557a73\tVerify Existing Account by Re-authentication\tReauthentication of existing account\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tf\tt\n1a20d932-8ceb-4ced-ad5a-a55a0c45ee55\tFirst broker login - Conditional OTP\tFlow to determine if the OTP is required for the authentication\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tf\tt\ne2b6b838-c360-4509-82c5-855c2ab4ed75\tsaml ecp\tSAML ECP Profile Authentication Flow\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tt\tt\n8360020f-cfaf-4fcc-8c00-6be8dc2edb68\tdocker auth\tUsed by Docker clients to authenticate against the IDP\tc4049252-49df-41a9-aef7-48d83ef55b9b\tbasic-flow\tt\tt\n471b9257-3796-4a4a-88b5-84d03dc770b6\tbrowser\tbrowser based authentication\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tt\tt\n1334bf46-99c7-4456-92b1-f9e46530c974\tforms\tUsername, password, otp and other auth forms.\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tf\tt\n163a45e7-8377-417a-8f97-7b2f420e53e3\tBrowser - Conditional OTP\tFlow to determine if the OTP is required for the authentication\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tf\tt\n9117851c-72ce-484f-9056-02c546f2af03\tdirect grant\tOpenID Connect Resource Owner Grant\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tt\tt\n84010cb8-1db6-4c4b-b93d-0cec9ab889da\tDirect Grant - Conditional OTP\tFlow to determine if the OTP is required for the authentication\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tf\tt\n994ce356-a898-4e82-9be8-f0b19ba4f3e6\tregistration\tregistration flow\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tt\tt\nea5719b7-f2d1-4c09-bc04-d768d2168362\tregistration form\tregistration form\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tform-flow\tf\tt\nc9812133-8011-408d-bc85-9857e0964b9f\treset credentials\tReset credentials for a user if they forgot their password or something\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tt\tt\n50ddcefb-d2bf-4fed-9b51-88e91f438584\tReset - Conditional OTP\tFlow to determine if the OTP should be reset or not. Set to REQUIRED to force.\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tf\tt\n906fb94d-9f4d-490b-8c43-f29a304b9bed\tclients\tBase authentication for clients\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tclient-flow\tt\tt\n710158d0-879b-44b1-a065-f6429712db02\tfirst broker login\tActions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tt\tt\nbd6a8301-eba6-4c64-8130-7fcbe6a67ec6\tUser creation or linking\tFlow for the existing/non-existing user alternatives\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tf\tt\n6d8ebb24-d795-46de-bb64-3bbe488bffce\tHandle Existing Account\tHandle what to do if there is existing account with same email/username like authenticated identity provider\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tf\tt\n594e65ee-5d11-430f-8249-d3ecab6735ad\tAccount verification options\tMethod with which to verity the existing account\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tf\tt\n33ca1bb1-e14a-44c5-a0c1-b3fd94db0ff3\tVerify Existing Account by Re-authentication\tReauthentication of existing account\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tf\tt\n035a0e9e-0005-40dc-b8f1-3cd8f92ceb7e\tFirst broker login - Conditional OTP\tFlow to determine if the OTP is required for the authentication\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tf\tt\n57ee353e-d235-4a46-97fa-5d86f5b31faf\tsaml ecp\tSAML ECP Profile Authentication Flow\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tt\tt\nff236ccc-385f-4d41-b325-95de0d50bfd5\tdocker auth\tUsed by Docker clients to authenticate against the IDP\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tbasic-flow\tt\tt\n\\.\n\n\n--\n-- Data for Name: authenticator_config; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.authenticator_config (id, alias, realm_id) FROM stdin;\n42d71774-ece2-4d90-9768-7b86133e639c\treview profile config\tc4049252-49df-41a9-aef7-48d83ef55b9b\n6a6dfcc6-3608-4c81-ba15-6ea35ec3cd31\tcreate unique user config\tc4049252-49df-41a9-aef7-48d83ef55b9b\n75c4e6c1-7d58-435f-bb8e-cd18c5183180\treview profile config\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\n6674de47-71e7-43dd-9523-c9ee1fcdc3bb\tcreate unique user config\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\n\\.\n\n\n--\n-- Data for Name: authenticator_config_entry; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.authenticator_config_entry (authenticator_id, value, name) FROM stdin;\n42d71774-ece2-4d90-9768-7b86133e639c\tmissing\tupdate.profile.on.first.login\n6a6dfcc6-3608-4c81-ba15-6ea35ec3cd31\tfalse\trequire.password.update.after.registration\n6674de47-71e7-43dd-9523-c9ee1fcdc3bb\tfalse\trequire.password.update.after.registration\n75c4e6c1-7d58-435f-bb8e-cd18c5183180\tmissing\tupdate.profile.on.first.login\n\\.\n\n\n--\n-- Data for Name: broker_link; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.broker_link (identity_provider, storage_provider_id, realm_id, broker_user_id, broker_username, token, user_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: client; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client (id, enabled, full_scope_allowed, client_id, not_before, public_client, secret, base_url, bearer_only, management_url, surrogate_auth_required, realm_id, protocol, node_rereg_timeout, frontchannel_logout, consent_required, name, service_accounts_enabled, client_authenticator_type, root_url, description, registration_token, standard_flow_enabled, implicit_flow_enabled, direct_access_grants_enabled, always_display_in_console) FROM stdin;\n132571df-6d24-4658-b2eb-d230a357820c\tt\tf\tmaster-realm\t0\tf\t\\N\t\\N\tt\t\\N\tf\tc4049252-49df-41a9-aef7-48d83ef55b9b\t\\N\t0\tf\tf\tmaster Realm\tf\tclient-secret\t\\N\t\\N\t\\N\tt\tf\tf\tf\ne8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\tt\tf\taccount\t0\tt\t\\N\t/realms/master/account/\tf\t\\N\tf\tc4049252-49df-41a9-aef7-48d83ef55b9b\topenid-connect\t0\tf\tf\t${client_account}\tf\tclient-secret\t${authBaseUrl}\t\\N\t\\N\tt\tf\tf\tf\n1edb53f6-3936-46c8-8329-4c694730bebb\tt\tf\taccount-console\t0\tt\t\\N\t/realms/master/account/\tf\t\\N\tf\tc4049252-49df-41a9-aef7-48d83ef55b9b\topenid-connect\t0\tf\tf\t${client_account-console}\tf\tclient-secret\t${authBaseUrl}\t\\N\t\\N\tt\tf\tf\tf\nd5552f1a-d891-405a-a414-9da39d032cd1\tt\tf\tbroker\t0\tf\t\\N\t\\N\tt\t\\N\tf\tc4049252-49df-41a9-aef7-48d83ef55b9b\topenid-connect\t0\tf\tf\t${client_broker}\tf\tclient-secret\t\\N\t\\N\t\\N\tt\tf\tf\tf\n5edf2eb5-b5b0-4498-9670-afad3c6058bd\tt\tf\tsecurity-admin-console\t0\tt\t\\N\t/admin/master/console/\tf\t\\N\tf\tc4049252-49df-41a9-aef7-48d83ef55b9b\topenid-connect\t0\tf\tf\t${client_security-admin-console}\tf\tclient-secret\t${authAdminUrl}\t\\N\t\\N\tt\tf\tf\tf\nbb68da0e-abf3-47dd-9db9-4ffe414d888f\tt\tf\tadmin-cli\t0\tt\t\\N\t\\N\tf\t\\N\tf\tc4049252-49df-41a9-aef7-48d83ef55b9b\topenid-connect\t0\tf\tf\t${client_admin-cli}\tf\tclient-secret\t\\N\t\\N\t\\N\tf\tf\tt\tf\n0405a18f-edff-4184-970f-71159d064fe0\tt\tf\tourboard-realm\t0\tf\t\\N\t\\N\tt\t\\N\tf\tc4049252-49df-41a9-aef7-48d83ef55b9b\t\\N\t0\tf\tf\tourboard Realm\tf\tclient-secret\t\\N\t\\N\t\\N\tt\tf\tf\tf\n24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\tf\trealm-management\t0\tf\t\\N\t\\N\tt\t\\N\tf\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\topenid-connect\t0\tf\tf\t${client_realm-management}\tf\tclient-secret\t\\N\t\\N\t\\N\tt\tf\tf\tf\nc49b94f0-f612-4dc8-816e-1fa9f0409a97\tt\tf\taccount\t0\tt\t\\N\t/realms/ourboard/account/\tf\t\\N\tf\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\topenid-connect\t0\tf\tf\t${client_account}\tf\tclient-secret\t${authBaseUrl}\t\\N\t\\N\tt\tf\tf\tf\n88d1b5bf-8de3-4650-ab47-03e482718370\tt\tf\taccount-console\t0\tt\t\\N\t/realms/ourboard/account/\tf\t\\N\tf\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\topenid-connect\t0\tf\tf\t${client_account-console}\tf\tclient-secret\t${authBaseUrl}\t\\N\t\\N\tt\tf\tf\tf\nc498c5a3-6c1b-415c-8c9e-f0eedccf567d\tt\tf\tbroker\t0\tf\t\\N\t\\N\tt\t\\N\tf\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\topenid-connect\t0\tf\tf\t${client_broker}\tf\tclient-secret\t\\N\t\\N\t\\N\tt\tf\tf\tf\n8322058b-b4d7-4556-9d03-b35b959fedfe\tt\tf\tsecurity-admin-console\t0\tt\t\\N\t/admin/ourboard/console/\tf\t\\N\tf\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\topenid-connect\t0\tf\tf\t${client_security-admin-console}\tf\tclient-secret\t${authAdminUrl}\t\\N\t\\N\tt\tf\tf\tf\nf707c508-e165-47bb-98de-5ea44681811c\tt\tf\tadmin-cli\t0\tt\t\\N\t\\N\tf\t\\N\tf\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\topenid-connect\t0\tf\tf\t${client_admin-cli}\tf\tclient-secret\t\\N\t\\N\t\\N\tf\tf\tt\tf\ned37d3b3-4644-4240-80c5-81954eb2cb6c\tt\tt\tourboard\t0\tf\tS2qHjCg12IDxz89Lffo49NQ19ooWCUwF\t\tf\thttp://localhost:1337\tf\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\topenid-connect\t-1\tt\tf\t\tf\tclient-secret\thttp://localhost:1337\t\t\\N\tt\tf\tf\tf\n\\.\n\n\n--\n-- Data for Name: client_attributes; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client_attributes (client_id, name, value) FROM stdin;\ne8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\tpost.logout.redirect.uris\t+\n1edb53f6-3936-46c8-8329-4c694730bebb\tpost.logout.redirect.uris\t+\n1edb53f6-3936-46c8-8329-4c694730bebb\tpkce.code.challenge.method\tS256\n5edf2eb5-b5b0-4498-9670-afad3c6058bd\tpost.logout.redirect.uris\t+\n5edf2eb5-b5b0-4498-9670-afad3c6058bd\tpkce.code.challenge.method\tS256\nc49b94f0-f612-4dc8-816e-1fa9f0409a97\tpost.logout.redirect.uris\t+\n88d1b5bf-8de3-4650-ab47-03e482718370\tpost.logout.redirect.uris\t+\n88d1b5bf-8de3-4650-ab47-03e482718370\tpkce.code.challenge.method\tS256\n8322058b-b4d7-4556-9d03-b35b959fedfe\tpost.logout.redirect.uris\t+\n8322058b-b4d7-4556-9d03-b35b959fedfe\tpkce.code.challenge.method\tS256\ned37d3b3-4644-4240-80c5-81954eb2cb6c\tclient.secret.creation.time\t1698594787\ned37d3b3-4644-4240-80c5-81954eb2cb6c\toauth2.device.authorization.grant.enabled\tfalse\ned37d3b3-4644-4240-80c5-81954eb2cb6c\toidc.ciba.grant.enabled\tfalse\ned37d3b3-4644-4240-80c5-81954eb2cb6c\tbackchannel.logout.session.required\ttrue\ned37d3b3-4644-4240-80c5-81954eb2cb6c\tbackchannel.logout.revoke.offline.tokens\tfalse\n\\.\n\n\n--\n-- Data for Name: client_auth_flow_bindings; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client_auth_flow_bindings (client_id, flow_id, binding_name) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: client_initial_access; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client_initial_access (id, realm_id, \"timestamp\", expiration, count, remaining_count) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: client_node_registrations; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client_node_registrations (client_id, value, name) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: client_scope; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client_scope (id, name, realm_id, description, protocol) FROM stdin;\n0a22b80a-3a76-4f77-bd61-57f3cadd2be9\toffline_access\tc4049252-49df-41a9-aef7-48d83ef55b9b\tOpenID Connect built-in scope: offline_access\topenid-connect\n5cdc5c81-7fb6-4f44-8700-a7c3adff5f88\trole_list\tc4049252-49df-41a9-aef7-48d83ef55b9b\tSAML role list\tsaml\n8a0c40eb-d40d-4251-9b2d-76e3237c6607\tprofile\tc4049252-49df-41a9-aef7-48d83ef55b9b\tOpenID Connect built-in scope: profile\topenid-connect\n3d28a2fb-9ad1-4ca7-90c8-c33e99daa777\temail\tc4049252-49df-41a9-aef7-48d83ef55b9b\tOpenID Connect built-in scope: email\topenid-connect\nf298579b-7724-4565-af9c-6efdd6082860\taddress\tc4049252-49df-41a9-aef7-48d83ef55b9b\tOpenID Connect built-in scope: address\topenid-connect\na2686296-d3ad-4bf6-89ea-43721a012e03\tphone\tc4049252-49df-41a9-aef7-48d83ef55b9b\tOpenID Connect built-in scope: phone\topenid-connect\n96380d5b-e4a3-4735-8e58-2bceadd99129\troles\tc4049252-49df-41a9-aef7-48d83ef55b9b\tOpenID Connect scope for add user roles to the access token\topenid-connect\ndab6c561-22d3-4db8-8fc3-8903df2c82ed\tweb-origins\tc4049252-49df-41a9-aef7-48d83ef55b9b\tOpenID Connect scope for add allowed web origins to the access token\topenid-connect\n246a65b1-ff00-4e73-beb6-a25f394e2e47\tmicroprofile-jwt\tc4049252-49df-41a9-aef7-48d83ef55b9b\tMicroprofile - JWT built-in scope\topenid-connect\nb1ddd251-07ac-4998-b90c-dd8d6a349d31\tacr\tc4049252-49df-41a9-aef7-48d83ef55b9b\tOpenID Connect scope for add acr (authentication context class reference) to the token\topenid-connect\n9cedb86c-28d0-42b5-945f-e0f11a78c5e3\toffline_access\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tOpenID Connect built-in scope: offline_access\topenid-connect\nb43942d2-5391-4b9c-ad0f-d6661bd9937d\trole_list\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tSAML role list\tsaml\n0eaf6d7e-2d08-47d1-8894-52d185c66ff3\tprofile\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tOpenID Connect built-in scope: profile\topenid-connect\n2b788c09-2a4a-4b8b-9cdd-22160e1456ba\temail\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tOpenID Connect built-in scope: email\topenid-connect\n9b2dad39-8598-4b0b-b6a5-0d1a7773e6d1\taddress\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tOpenID Connect built-in scope: address\topenid-connect\n8074c632-a4ac-4680-a066-261a3cf0c641\tphone\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tOpenID Connect built-in scope: phone\topenid-connect\n2c3494c0-4620-4a38-85e1-b5a21f7d89fa\troles\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tOpenID Connect scope for add user roles to the access token\topenid-connect\n1719ac57-9b55-456f-a72d-ef7310fc6e18\tweb-origins\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tOpenID Connect scope for add allowed web origins to the access token\topenid-connect\n0943b915-e341-4e62-8b3f-2fb952439ba8\tmicroprofile-jwt\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tMicroprofile - JWT built-in scope\topenid-connect\n797314fe-1913-40f4-8efb-44123269c71f\tacr\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tOpenID Connect scope for add acr (authentication context class reference) to the token\topenid-connect\n\\.\n\n\n--\n-- Data for Name: client_scope_attributes; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client_scope_attributes (scope_id, value, name) FROM stdin;\n0a22b80a-3a76-4f77-bd61-57f3cadd2be9\ttrue\tdisplay.on.consent.screen\n0a22b80a-3a76-4f77-bd61-57f3cadd2be9\t${offlineAccessScopeConsentText}\tconsent.screen.text\n5cdc5c81-7fb6-4f44-8700-a7c3adff5f88\ttrue\tdisplay.on.consent.screen\n5cdc5c81-7fb6-4f44-8700-a7c3adff5f88\t${samlRoleListScopeConsentText}\tconsent.screen.text\n8a0c40eb-d40d-4251-9b2d-76e3237c6607\ttrue\tdisplay.on.consent.screen\n8a0c40eb-d40d-4251-9b2d-76e3237c6607\t${profileScopeConsentText}\tconsent.screen.text\n8a0c40eb-d40d-4251-9b2d-76e3237c6607\ttrue\tinclude.in.token.scope\n3d28a2fb-9ad1-4ca7-90c8-c33e99daa777\ttrue\tdisplay.on.consent.screen\n3d28a2fb-9ad1-4ca7-90c8-c33e99daa777\t${emailScopeConsentText}\tconsent.screen.text\n3d28a2fb-9ad1-4ca7-90c8-c33e99daa777\ttrue\tinclude.in.token.scope\nf298579b-7724-4565-af9c-6efdd6082860\ttrue\tdisplay.on.consent.screen\nf298579b-7724-4565-af9c-6efdd6082860\t${addressScopeConsentText}\tconsent.screen.text\nf298579b-7724-4565-af9c-6efdd6082860\ttrue\tinclude.in.token.scope\na2686296-d3ad-4bf6-89ea-43721a012e03\ttrue\tdisplay.on.consent.screen\na2686296-d3ad-4bf6-89ea-43721a012e03\t${phoneScopeConsentText}\tconsent.screen.text\na2686296-d3ad-4bf6-89ea-43721a012e03\ttrue\tinclude.in.token.scope\n96380d5b-e4a3-4735-8e58-2bceadd99129\ttrue\tdisplay.on.consent.screen\n96380d5b-e4a3-4735-8e58-2bceadd99129\t${rolesScopeConsentText}\tconsent.screen.text\n96380d5b-e4a3-4735-8e58-2bceadd99129\tfalse\tinclude.in.token.scope\ndab6c561-22d3-4db8-8fc3-8903df2c82ed\tfalse\tdisplay.on.consent.screen\ndab6c561-22d3-4db8-8fc3-8903df2c82ed\t\tconsent.screen.text\ndab6c561-22d3-4db8-8fc3-8903df2c82ed\tfalse\tinclude.in.token.scope\n246a65b1-ff00-4e73-beb6-a25f394e2e47\tfalse\tdisplay.on.consent.screen\n246a65b1-ff00-4e73-beb6-a25f394e2e47\ttrue\tinclude.in.token.scope\nb1ddd251-07ac-4998-b90c-dd8d6a349d31\tfalse\tdisplay.on.consent.screen\nb1ddd251-07ac-4998-b90c-dd8d6a349d31\tfalse\tinclude.in.token.scope\n9cedb86c-28d0-42b5-945f-e0f11a78c5e3\ttrue\tdisplay.on.consent.screen\n9cedb86c-28d0-42b5-945f-e0f11a78c5e3\t${offlineAccessScopeConsentText}\tconsent.screen.text\nb43942d2-5391-4b9c-ad0f-d6661bd9937d\ttrue\tdisplay.on.consent.screen\nb43942d2-5391-4b9c-ad0f-d6661bd9937d\t${samlRoleListScopeConsentText}\tconsent.screen.text\n0eaf6d7e-2d08-47d1-8894-52d185c66ff3\ttrue\tdisplay.on.consent.screen\n0eaf6d7e-2d08-47d1-8894-52d185c66ff3\t${profileScopeConsentText}\tconsent.screen.text\n0eaf6d7e-2d08-47d1-8894-52d185c66ff3\ttrue\tinclude.in.token.scope\n2b788c09-2a4a-4b8b-9cdd-22160e1456ba\ttrue\tdisplay.on.consent.screen\n2b788c09-2a4a-4b8b-9cdd-22160e1456ba\t${emailScopeConsentText}\tconsent.screen.text\n2b788c09-2a4a-4b8b-9cdd-22160e1456ba\ttrue\tinclude.in.token.scope\n9b2dad39-8598-4b0b-b6a5-0d1a7773e6d1\ttrue\tdisplay.on.consent.screen\n9b2dad39-8598-4b0b-b6a5-0d1a7773e6d1\t${addressScopeConsentText}\tconsent.screen.text\n9b2dad39-8598-4b0b-b6a5-0d1a7773e6d1\ttrue\tinclude.in.token.scope\n8074c632-a4ac-4680-a066-261a3cf0c641\ttrue\tdisplay.on.consent.screen\n8074c632-a4ac-4680-a066-261a3cf0c641\t${phoneScopeConsentText}\tconsent.screen.text\n8074c632-a4ac-4680-a066-261a3cf0c641\ttrue\tinclude.in.token.scope\n2c3494c0-4620-4a38-85e1-b5a21f7d89fa\ttrue\tdisplay.on.consent.screen\n2c3494c0-4620-4a38-85e1-b5a21f7d89fa\t${rolesScopeConsentText}\tconsent.screen.text\n2c3494c0-4620-4a38-85e1-b5a21f7d89fa\tfalse\tinclude.in.token.scope\n1719ac57-9b55-456f-a72d-ef7310fc6e18\tfalse\tdisplay.on.consent.screen\n1719ac57-9b55-456f-a72d-ef7310fc6e18\t\tconsent.screen.text\n1719ac57-9b55-456f-a72d-ef7310fc6e18\tfalse\tinclude.in.token.scope\n0943b915-e341-4e62-8b3f-2fb952439ba8\tfalse\tdisplay.on.consent.screen\n0943b915-e341-4e62-8b3f-2fb952439ba8\ttrue\tinclude.in.token.scope\n797314fe-1913-40f4-8efb-44123269c71f\tfalse\tdisplay.on.consent.screen\n797314fe-1913-40f4-8efb-44123269c71f\tfalse\tinclude.in.token.scope\n\\.\n\n\n--\n-- Data for Name: client_scope_client; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client_scope_client (client_id, scope_id, default_scope) FROM stdin;\ne8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\tb1ddd251-07ac-4998-b90c-dd8d6a349d31\tt\ne8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\t3d28a2fb-9ad1-4ca7-90c8-c33e99daa777\tt\ne8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\t96380d5b-e4a3-4735-8e58-2bceadd99129\tt\ne8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\tdab6c561-22d3-4db8-8fc3-8903df2c82ed\tt\ne8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\tt\ne8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\t0a22b80a-3a76-4f77-bd61-57f3cadd2be9\tf\ne8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\tf298579b-7724-4565-af9c-6efdd6082860\tf\ne8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\ta2686296-d3ad-4bf6-89ea-43721a012e03\tf\ne8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\t246a65b1-ff00-4e73-beb6-a25f394e2e47\tf\n1edb53f6-3936-46c8-8329-4c694730bebb\tb1ddd251-07ac-4998-b90c-dd8d6a349d31\tt\n1edb53f6-3936-46c8-8329-4c694730bebb\t3d28a2fb-9ad1-4ca7-90c8-c33e99daa777\tt\n1edb53f6-3936-46c8-8329-4c694730bebb\t96380d5b-e4a3-4735-8e58-2bceadd99129\tt\n1edb53f6-3936-46c8-8329-4c694730bebb\tdab6c561-22d3-4db8-8fc3-8903df2c82ed\tt\n1edb53f6-3936-46c8-8329-4c694730bebb\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\tt\n1edb53f6-3936-46c8-8329-4c694730bebb\t0a22b80a-3a76-4f77-bd61-57f3cadd2be9\tf\n1edb53f6-3936-46c8-8329-4c694730bebb\tf298579b-7724-4565-af9c-6efdd6082860\tf\n1edb53f6-3936-46c8-8329-4c694730bebb\ta2686296-d3ad-4bf6-89ea-43721a012e03\tf\n1edb53f6-3936-46c8-8329-4c694730bebb\t246a65b1-ff00-4e73-beb6-a25f394e2e47\tf\nbb68da0e-abf3-47dd-9db9-4ffe414d888f\tb1ddd251-07ac-4998-b90c-dd8d6a349d31\tt\nbb68da0e-abf3-47dd-9db9-4ffe414d888f\t3d28a2fb-9ad1-4ca7-90c8-c33e99daa777\tt\nbb68da0e-abf3-47dd-9db9-4ffe414d888f\t96380d5b-e4a3-4735-8e58-2bceadd99129\tt\nbb68da0e-abf3-47dd-9db9-4ffe414d888f\tdab6c561-22d3-4db8-8fc3-8903df2c82ed\tt\nbb68da0e-abf3-47dd-9db9-4ffe414d888f\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\tt\nbb68da0e-abf3-47dd-9db9-4ffe414d888f\t0a22b80a-3a76-4f77-bd61-57f3cadd2be9\tf\nbb68da0e-abf3-47dd-9db9-4ffe414d888f\tf298579b-7724-4565-af9c-6efdd6082860\tf\nbb68da0e-abf3-47dd-9db9-4ffe414d888f\ta2686296-d3ad-4bf6-89ea-43721a012e03\tf\nbb68da0e-abf3-47dd-9db9-4ffe414d888f\t246a65b1-ff00-4e73-beb6-a25f394e2e47\tf\nd5552f1a-d891-405a-a414-9da39d032cd1\tb1ddd251-07ac-4998-b90c-dd8d6a349d31\tt\nd5552f1a-d891-405a-a414-9da39d032cd1\t3d28a2fb-9ad1-4ca7-90c8-c33e99daa777\tt\nd5552f1a-d891-405a-a414-9da39d032cd1\t96380d5b-e4a3-4735-8e58-2bceadd99129\tt\nd5552f1a-d891-405a-a414-9da39d032cd1\tdab6c561-22d3-4db8-8fc3-8903df2c82ed\tt\nd5552f1a-d891-405a-a414-9da39d032cd1\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\tt\nd5552f1a-d891-405a-a414-9da39d032cd1\t0a22b80a-3a76-4f77-bd61-57f3cadd2be9\tf\nd5552f1a-d891-405a-a414-9da39d032cd1\tf298579b-7724-4565-af9c-6efdd6082860\tf\nd5552f1a-d891-405a-a414-9da39d032cd1\ta2686296-d3ad-4bf6-89ea-43721a012e03\tf\nd5552f1a-d891-405a-a414-9da39d032cd1\t246a65b1-ff00-4e73-beb6-a25f394e2e47\tf\n132571df-6d24-4658-b2eb-d230a357820c\tb1ddd251-07ac-4998-b90c-dd8d6a349d31\tt\n132571df-6d24-4658-b2eb-d230a357820c\t3d28a2fb-9ad1-4ca7-90c8-c33e99daa777\tt\n132571df-6d24-4658-b2eb-d230a357820c\t96380d5b-e4a3-4735-8e58-2bceadd99129\tt\n132571df-6d24-4658-b2eb-d230a357820c\tdab6c561-22d3-4db8-8fc3-8903df2c82ed\tt\n132571df-6d24-4658-b2eb-d230a357820c\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\tt\n132571df-6d24-4658-b2eb-d230a357820c\t0a22b80a-3a76-4f77-bd61-57f3cadd2be9\tf\n132571df-6d24-4658-b2eb-d230a357820c\tf298579b-7724-4565-af9c-6efdd6082860\tf\n132571df-6d24-4658-b2eb-d230a357820c\ta2686296-d3ad-4bf6-89ea-43721a012e03\tf\n132571df-6d24-4658-b2eb-d230a357820c\t246a65b1-ff00-4e73-beb6-a25f394e2e47\tf\n5edf2eb5-b5b0-4498-9670-afad3c6058bd\tb1ddd251-07ac-4998-b90c-dd8d6a349d31\tt\n5edf2eb5-b5b0-4498-9670-afad3c6058bd\t3d28a2fb-9ad1-4ca7-90c8-c33e99daa777\tt\n5edf2eb5-b5b0-4498-9670-afad3c6058bd\t96380d5b-e4a3-4735-8e58-2bceadd99129\tt\n5edf2eb5-b5b0-4498-9670-afad3c6058bd\tdab6c561-22d3-4db8-8fc3-8903df2c82ed\tt\n5edf2eb5-b5b0-4498-9670-afad3c6058bd\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\tt\n5edf2eb5-b5b0-4498-9670-afad3c6058bd\t0a22b80a-3a76-4f77-bd61-57f3cadd2be9\tf\n5edf2eb5-b5b0-4498-9670-afad3c6058bd\tf298579b-7724-4565-af9c-6efdd6082860\tf\n5edf2eb5-b5b0-4498-9670-afad3c6058bd\ta2686296-d3ad-4bf6-89ea-43721a012e03\tf\n5edf2eb5-b5b0-4498-9670-afad3c6058bd\t246a65b1-ff00-4e73-beb6-a25f394e2e47\tf\nc49b94f0-f612-4dc8-816e-1fa9f0409a97\t2b788c09-2a4a-4b8b-9cdd-22160e1456ba\tt\nc49b94f0-f612-4dc8-816e-1fa9f0409a97\t2c3494c0-4620-4a38-85e1-b5a21f7d89fa\tt\nc49b94f0-f612-4dc8-816e-1fa9f0409a97\t1719ac57-9b55-456f-a72d-ef7310fc6e18\tt\nc49b94f0-f612-4dc8-816e-1fa9f0409a97\t797314fe-1913-40f4-8efb-44123269c71f\tt\nc49b94f0-f612-4dc8-816e-1fa9f0409a97\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\tt\nc49b94f0-f612-4dc8-816e-1fa9f0409a97\t0943b915-e341-4e62-8b3f-2fb952439ba8\tf\nc49b94f0-f612-4dc8-816e-1fa9f0409a97\t8074c632-a4ac-4680-a066-261a3cf0c641\tf\nc49b94f0-f612-4dc8-816e-1fa9f0409a97\t9b2dad39-8598-4b0b-b6a5-0d1a7773e6d1\tf\nc49b94f0-f612-4dc8-816e-1fa9f0409a97\t9cedb86c-28d0-42b5-945f-e0f11a78c5e3\tf\n88d1b5bf-8de3-4650-ab47-03e482718370\t2b788c09-2a4a-4b8b-9cdd-22160e1456ba\tt\n88d1b5bf-8de3-4650-ab47-03e482718370\t2c3494c0-4620-4a38-85e1-b5a21f7d89fa\tt\n88d1b5bf-8de3-4650-ab47-03e482718370\t1719ac57-9b55-456f-a72d-ef7310fc6e18\tt\n88d1b5bf-8de3-4650-ab47-03e482718370\t797314fe-1913-40f4-8efb-44123269c71f\tt\n88d1b5bf-8de3-4650-ab47-03e482718370\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\tt\n88d1b5bf-8de3-4650-ab47-03e482718370\t0943b915-e341-4e62-8b3f-2fb952439ba8\tf\n88d1b5bf-8de3-4650-ab47-03e482718370\t8074c632-a4ac-4680-a066-261a3cf0c641\tf\n88d1b5bf-8de3-4650-ab47-03e482718370\t9b2dad39-8598-4b0b-b6a5-0d1a7773e6d1\tf\n88d1b5bf-8de3-4650-ab47-03e482718370\t9cedb86c-28d0-42b5-945f-e0f11a78c5e3\tf\nf707c508-e165-47bb-98de-5ea44681811c\t2b788c09-2a4a-4b8b-9cdd-22160e1456ba\tt\nf707c508-e165-47bb-98de-5ea44681811c\t2c3494c0-4620-4a38-85e1-b5a21f7d89fa\tt\nf707c508-e165-47bb-98de-5ea44681811c\t1719ac57-9b55-456f-a72d-ef7310fc6e18\tt\nf707c508-e165-47bb-98de-5ea44681811c\t797314fe-1913-40f4-8efb-44123269c71f\tt\nf707c508-e165-47bb-98de-5ea44681811c\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\tt\nf707c508-e165-47bb-98de-5ea44681811c\t0943b915-e341-4e62-8b3f-2fb952439ba8\tf\nf707c508-e165-47bb-98de-5ea44681811c\t8074c632-a4ac-4680-a066-261a3cf0c641\tf\nf707c508-e165-47bb-98de-5ea44681811c\t9b2dad39-8598-4b0b-b6a5-0d1a7773e6d1\tf\nf707c508-e165-47bb-98de-5ea44681811c\t9cedb86c-28d0-42b5-945f-e0f11a78c5e3\tf\nc498c5a3-6c1b-415c-8c9e-f0eedccf567d\t2b788c09-2a4a-4b8b-9cdd-22160e1456ba\tt\nc498c5a3-6c1b-415c-8c9e-f0eedccf567d\t2c3494c0-4620-4a38-85e1-b5a21f7d89fa\tt\nc498c5a3-6c1b-415c-8c9e-f0eedccf567d\t1719ac57-9b55-456f-a72d-ef7310fc6e18\tt\nc498c5a3-6c1b-415c-8c9e-f0eedccf567d\t797314fe-1913-40f4-8efb-44123269c71f\tt\nc498c5a3-6c1b-415c-8c9e-f0eedccf567d\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\tt\nc498c5a3-6c1b-415c-8c9e-f0eedccf567d\t0943b915-e341-4e62-8b3f-2fb952439ba8\tf\nc498c5a3-6c1b-415c-8c9e-f0eedccf567d\t8074c632-a4ac-4680-a066-261a3cf0c641\tf\nc498c5a3-6c1b-415c-8c9e-f0eedccf567d\t9b2dad39-8598-4b0b-b6a5-0d1a7773e6d1\tf\nc498c5a3-6c1b-415c-8c9e-f0eedccf567d\t9cedb86c-28d0-42b5-945f-e0f11a78c5e3\tf\n24d6127f-a63c-4a42-a9db-de9e4ea85d09\t2b788c09-2a4a-4b8b-9cdd-22160e1456ba\tt\n24d6127f-a63c-4a42-a9db-de9e4ea85d09\t2c3494c0-4620-4a38-85e1-b5a21f7d89fa\tt\n24d6127f-a63c-4a42-a9db-de9e4ea85d09\t1719ac57-9b55-456f-a72d-ef7310fc6e18\tt\n24d6127f-a63c-4a42-a9db-de9e4ea85d09\t797314fe-1913-40f4-8efb-44123269c71f\tt\n24d6127f-a63c-4a42-a9db-de9e4ea85d09\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\tt\n24d6127f-a63c-4a42-a9db-de9e4ea85d09\t0943b915-e341-4e62-8b3f-2fb952439ba8\tf\n24d6127f-a63c-4a42-a9db-de9e4ea85d09\t8074c632-a4ac-4680-a066-261a3cf0c641\tf\n24d6127f-a63c-4a42-a9db-de9e4ea85d09\t9b2dad39-8598-4b0b-b6a5-0d1a7773e6d1\tf\n24d6127f-a63c-4a42-a9db-de9e4ea85d09\t9cedb86c-28d0-42b5-945f-e0f11a78c5e3\tf\n8322058b-b4d7-4556-9d03-b35b959fedfe\t2b788c09-2a4a-4b8b-9cdd-22160e1456ba\tt\n8322058b-b4d7-4556-9d03-b35b959fedfe\t2c3494c0-4620-4a38-85e1-b5a21f7d89fa\tt\n8322058b-b4d7-4556-9d03-b35b959fedfe\t1719ac57-9b55-456f-a72d-ef7310fc6e18\tt\n8322058b-b4d7-4556-9d03-b35b959fedfe\t797314fe-1913-40f4-8efb-44123269c71f\tt\n8322058b-b4d7-4556-9d03-b35b959fedfe\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\tt\n8322058b-b4d7-4556-9d03-b35b959fedfe\t0943b915-e341-4e62-8b3f-2fb952439ba8\tf\n8322058b-b4d7-4556-9d03-b35b959fedfe\t8074c632-a4ac-4680-a066-261a3cf0c641\tf\n8322058b-b4d7-4556-9d03-b35b959fedfe\t9b2dad39-8598-4b0b-b6a5-0d1a7773e6d1\tf\n8322058b-b4d7-4556-9d03-b35b959fedfe\t9cedb86c-28d0-42b5-945f-e0f11a78c5e3\tf\ned37d3b3-4644-4240-80c5-81954eb2cb6c\t2b788c09-2a4a-4b8b-9cdd-22160e1456ba\tt\ned37d3b3-4644-4240-80c5-81954eb2cb6c\t2c3494c0-4620-4a38-85e1-b5a21f7d89fa\tt\ned37d3b3-4644-4240-80c5-81954eb2cb6c\t1719ac57-9b55-456f-a72d-ef7310fc6e18\tt\ned37d3b3-4644-4240-80c5-81954eb2cb6c\t797314fe-1913-40f4-8efb-44123269c71f\tt\ned37d3b3-4644-4240-80c5-81954eb2cb6c\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\tt\ned37d3b3-4644-4240-80c5-81954eb2cb6c\t0943b915-e341-4e62-8b3f-2fb952439ba8\tf\ned37d3b3-4644-4240-80c5-81954eb2cb6c\t8074c632-a4ac-4680-a066-261a3cf0c641\tf\ned37d3b3-4644-4240-80c5-81954eb2cb6c\t9b2dad39-8598-4b0b-b6a5-0d1a7773e6d1\tf\ned37d3b3-4644-4240-80c5-81954eb2cb6c\t9cedb86c-28d0-42b5-945f-e0f11a78c5e3\tf\n\\.\n\n\n--\n-- Data for Name: client_scope_role_mapping; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client_scope_role_mapping (scope_id, role_id) FROM stdin;\n0a22b80a-3a76-4f77-bd61-57f3cadd2be9\t418f571e-3a36-4904-800a-c31d10ab4194\n9cedb86c-28d0-42b5-945f-e0f11a78c5e3\te5ad2c92-6fec-46ff-ac4c-0c519483a403\n\\.\n\n\n--\n-- Data for Name: client_session; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client_session (id, client_id, redirect_uri, state, \"timestamp\", session_id, auth_method, realm_id, auth_user_id, current_action) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: client_session_auth_status; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client_session_auth_status (authenticator, status, client_session) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: client_session_note; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client_session_note (name, value, client_session) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: client_session_prot_mapper; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client_session_prot_mapper (protocol_mapper_id, client_session) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: client_session_role; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client_session_role (role_id, client_session) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: client_user_session_note; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.client_user_session_note (name, value, client_session) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: component; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.component (id, name, parent_id, provider_id, provider_type, realm_id, sub_type) FROM stdin;\n93fb966f-0806-40d0-8212-3006a7f86691\tTrusted Hosts\tc4049252-49df-41a9-aef7-48d83ef55b9b\ttrusted-hosts\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\tc4049252-49df-41a9-aef7-48d83ef55b9b\tanonymous\n7978439e-20cd-466f-81aa-736e7b0f6655\tConsent Required\tc4049252-49df-41a9-aef7-48d83ef55b9b\tconsent-required\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\tc4049252-49df-41a9-aef7-48d83ef55b9b\tanonymous\n13d55f48-07aa-45bf-9b76-64a62d45ac00\tFull Scope Disabled\tc4049252-49df-41a9-aef7-48d83ef55b9b\tscope\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\tc4049252-49df-41a9-aef7-48d83ef55b9b\tanonymous\n0ec19092-12c0-40f4-9a74-40ea13444f2b\tMax Clients Limit\tc4049252-49df-41a9-aef7-48d83ef55b9b\tmax-clients\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\tc4049252-49df-41a9-aef7-48d83ef55b9b\tanonymous\nf5e2f9ef-67fb-47d8-aeba-e1d7bbc5c9c6\tAllowed Protocol Mapper Types\tc4049252-49df-41a9-aef7-48d83ef55b9b\tallowed-protocol-mappers\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\tc4049252-49df-41a9-aef7-48d83ef55b9b\tanonymous\n0474fc03-c3a2-47d6-b8f8-c0242abf7277\tAllowed Client Scopes\tc4049252-49df-41a9-aef7-48d83ef55b9b\tallowed-client-templates\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\tc4049252-49df-41a9-aef7-48d83ef55b9b\tanonymous\neefab089-bcb3-4d02-b2fb-74177a5118f4\tAllowed Protocol Mapper Types\tc4049252-49df-41a9-aef7-48d83ef55b9b\tallowed-protocol-mappers\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\tc4049252-49df-41a9-aef7-48d83ef55b9b\tauthenticated\n3c23e554-56ba-4e97-ac92-7b26b27d9f44\tAllowed Client Scopes\tc4049252-49df-41a9-aef7-48d83ef55b9b\tallowed-client-templates\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\tc4049252-49df-41a9-aef7-48d83ef55b9b\tauthenticated\n81a8b0c4-001a-440b-9cc7-6ae00fee5cab\trsa-generated\tc4049252-49df-41a9-aef7-48d83ef55b9b\trsa-generated\torg.keycloak.keys.KeyProvider\tc4049252-49df-41a9-aef7-48d83ef55b9b\t\\N\n901a3f02-9c01-41b1-994c-ff98a945d8fb\trsa-enc-generated\tc4049252-49df-41a9-aef7-48d83ef55b9b\trsa-enc-generated\torg.keycloak.keys.KeyProvider\tc4049252-49df-41a9-aef7-48d83ef55b9b\t\\N\nf2c10a5d-ccfd-4871-a6b3-63d38dfdc07f\thmac-generated\tc4049252-49df-41a9-aef7-48d83ef55b9b\thmac-generated\torg.keycloak.keys.KeyProvider\tc4049252-49df-41a9-aef7-48d83ef55b9b\t\\N\nd47fbb5d-a6b1-4d79-aa5f-90ab251a8791\taes-generated\tc4049252-49df-41a9-aef7-48d83ef55b9b\taes-generated\torg.keycloak.keys.KeyProvider\tc4049252-49df-41a9-aef7-48d83ef55b9b\t\\N\nfdc0d5bc-0668-4fda-838d-73fb8240c295\trsa-generated\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\trsa-generated\torg.keycloak.keys.KeyProvider\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t\\N\nbb53904f-cdd6-481b-9379-2aa79eae2100\trsa-enc-generated\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\trsa-enc-generated\torg.keycloak.keys.KeyProvider\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t\\N\n58668ed5-7460-492e-8c0c-bae21e11aeae\thmac-generated\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\thmac-generated\torg.keycloak.keys.KeyProvider\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t\\N\n1f84d56e-1fb0-4e65-9e2e-61bee1bb8b5f\taes-generated\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\taes-generated\torg.keycloak.keys.KeyProvider\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t\\N\n8b8026ea-2fe2-48a4-92c7-8282d292f7b0\tTrusted Hosts\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\ttrusted-hosts\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tanonymous\n7cf93bf0-7cd9-4617-8d6a-63c8486427aa\tConsent Required\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tconsent-required\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tanonymous\nd11f165f-d5da-467e-839d-d4a013de423e\tFull Scope Disabled\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tscope\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tanonymous\n8fb797ed-1282-4850-b960-adfb28daf4a6\tMax Clients Limit\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tmax-clients\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tanonymous\n6d9d2d89-897a-427a-8b9c-04d0448419e5\tAllowed Protocol Mapper Types\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tallowed-protocol-mappers\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tanonymous\n7a4f8643-ecb5-44c9-884b-40ce6c215bb1\tAllowed Client Scopes\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tallowed-client-templates\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tanonymous\n94a0b3de-82e6-47fa-95f5-113b4c830202\tAllowed Protocol Mapper Types\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tallowed-protocol-mappers\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tauthenticated\n9853f82e-afb6-4537-ba7c-e1644b4dae3e\tAllowed Client Scopes\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tallowed-client-templates\torg.keycloak.services.clientregistration.policy.ClientRegistrationPolicy\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tauthenticated\n\\.\n\n\n--\n-- Data for Name: component_config; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.component_config (id, component_id, name, value) FROM stdin;\n7d7f7f83-bad0-41b7-a246-37a62855e34d\t93fb966f-0806-40d0-8212-3006a7f86691\thost-sending-registration-request-must-match\ttrue\n7e2f13d2-eb94-4f7c-9796-8b2a76cd638d\t93fb966f-0806-40d0-8212-3006a7f86691\tclient-uris-must-match\ttrue\n2c28ee8d-66f3-4c54-9a01-5b5909f882e2\t0ec19092-12c0-40f4-9a74-40ea13444f2b\tmax-clients\t200\n5531eb57-7443-4ed9-9869-d58aa5810709\t3c23e554-56ba-4e97-ac92-7b26b27d9f44\tallow-default-scopes\ttrue\n1c08bdee-c2b8-4c72-a649-aa2490634450\t0474fc03-c3a2-47d6-b8f8-c0242abf7277\tallow-default-scopes\ttrue\n454b2940-5ede-4663-bcce-9ae2855b8226\tf5e2f9ef-67fb-47d8-aeba-e1d7bbc5c9c6\tallowed-protocol-mapper-types\toidc-usermodel-property-mapper\na23bf9ea-762a-41e2-a96e-e84c12faf29d\tf5e2f9ef-67fb-47d8-aeba-e1d7bbc5c9c6\tallowed-protocol-mapper-types\toidc-full-name-mapper\n537fea15-7305-4222-856d-cf2b6279def3\tf5e2f9ef-67fb-47d8-aeba-e1d7bbc5c9c6\tallowed-protocol-mapper-types\tsaml-role-list-mapper\ncf877816-cd3d-46eb-bf0f-c38f87262f55\tf5e2f9ef-67fb-47d8-aeba-e1d7bbc5c9c6\tallowed-protocol-mapper-types\toidc-sha256-pairwise-sub-mapper\n98dcf3c1-14a8-4a2c-af20-fbc65b1af22c\tf5e2f9ef-67fb-47d8-aeba-e1d7bbc5c9c6\tallowed-protocol-mapper-types\toidc-usermodel-attribute-mapper\nb2fb24d6-78e3-467b-89f6-892019e6356c\tf5e2f9ef-67fb-47d8-aeba-e1d7bbc5c9c6\tallowed-protocol-mapper-types\toidc-address-mapper\n487e284c-f60b-4b91-8900-9c2871fbb541\tf5e2f9ef-67fb-47d8-aeba-e1d7bbc5c9c6\tallowed-protocol-mapper-types\tsaml-user-property-mapper\n05056456-9dc7-4834-a1c0-8be3e8bb7378\tf5e2f9ef-67fb-47d8-aeba-e1d7bbc5c9c6\tallowed-protocol-mapper-types\tsaml-user-attribute-mapper\n4a96dbcd-e0e2-4cb9-920d-63d873427b8a\teefab089-bcb3-4d02-b2fb-74177a5118f4\tallowed-protocol-mapper-types\toidc-full-name-mapper\n87caba6b-bc59-450b-ba0c-9b9947f50529\teefab089-bcb3-4d02-b2fb-74177a5118f4\tallowed-protocol-mapper-types\tsaml-user-attribute-mapper\n9d857cb2-620b-4e2b-8d72-eeae0d643b4a\teefab089-bcb3-4d02-b2fb-74177a5118f4\tallowed-protocol-mapper-types\toidc-usermodel-property-mapper\nc1e8d8b7-a9b5-4ec8-9722-5f5c7945d4c8\teefab089-bcb3-4d02-b2fb-74177a5118f4\tallowed-protocol-mapper-types\toidc-sha256-pairwise-sub-mapper\n14b9647e-12ae-4039-a42c-33beaeeecdd8\teefab089-bcb3-4d02-b2fb-74177a5118f4\tallowed-protocol-mapper-types\toidc-address-mapper\n9db3ec35-b66a-492a-9be8-097d5ae7546a\teefab089-bcb3-4d02-b2fb-74177a5118f4\tallowed-protocol-mapper-types\toidc-usermodel-attribute-mapper\n05dcce56-9931-4cd1-a3a8-8bfc9480a09f\teefab089-bcb3-4d02-b2fb-74177a5118f4\tallowed-protocol-mapper-types\tsaml-role-list-mapper\n88e6f33b-fae4-494c-b82c-401f1fa292fe\teefab089-bcb3-4d02-b2fb-74177a5118f4\tallowed-protocol-mapper-types\tsaml-user-property-mapper\n2419f695-1816-4123-8710-c3aa34b8eea9\td47fbb5d-a6b1-4d79-aa5f-90ab251a8791\tkid\t81898ba5-b00c-4177-8ea6-b534c587d320\n87ac3386-d553-4e20-893f-0ff925fd0280\td47fbb5d-a6b1-4d79-aa5f-90ab251a8791\tsecret\tMTuT38MLB3kpuwTtEjQ-tQ\ne108b9e4-eb75-4fe8-b766-5b295a993171\td47fbb5d-a6b1-4d79-aa5f-90ab251a8791\tpriority\t100\n4d2faf29-823f-4a01-9537-6cd075eba00a\t901a3f02-9c01-41b1-994c-ff98a945d8fb\tkeyUse\tENC\nbf63c791-58b8-4596-adbf-03860cbbce0d\t901a3f02-9c01-41b1-994c-ff98a945d8fb\tcertificate\tMIICmzCCAYMCBgGLfBd2WDANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjMxMDI5MTUzODE0WhcNMzMxMDI5MTUzOTU0WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq9Im0MPPFZXA0HjeL2UaapXGQOvpOyJNgf6RQFQ4qzNECft0TW5mdUSbW7HBBL00PJjMQJsE9mmNawbhmSv06xECqV1IOK9p1hd9S6+ckGmq0zbGyS2p3GhUgqAfKulCobvjQcNADZjbHv12QXsLEWhOmsbqLQQIwVZl07aUMEs/mUOW9a9ELFVlZOPMw7JvOPR9YDco4JLxd3z/onAP9M9Oc0alYnTBuifubJ+D1m1e17MFWCGaE7ElzqYgFENc7j45SbdhPly4cmbmiLF3SIzafNn4yAa34eskfxamJRHGjZxFtWIR17wSyRdnQZk2ysWavOQuSng2QJ5efSEaHAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAGPtpvAffi+zw0ema5mPuEqXOfFwk687YUgcJhmrZbbwJGg2ppso3kJSSesqOK41Jt37+mYE9bnzS5sKeBRszpUVzhzXLtoBEBAwFpm6UAPV+bjlWkvYvB4bydLy4VK7sHSxoCSLhZtHNnb9/03yPsIDMA4w6DRyz7DjPd1zZG/ioln+xgjSO57jbhZbFLbPo889jt1oByZHgD7wfsrrnxs8M3pfDpPaYFqQf6wfxQS0VMLi1gS9glOHs+aAJvpEQGMvGaXFfR3jdbxwG0vpQNCeOBU3VXfgxA845IfZWhMGRt5pc/0xOVX0Cu67d6YAvdY9bEqrXopq3Srv3CPyhRw=\nd13657ed-9f90-4366-af97-d7476cd94ca3\t901a3f02-9c01-41b1-994c-ff98a945d8fb\tpriority\t100\nb21fd4d3-a380-4625-b76a-6b1bb2ed9950\t901a3f02-9c01-41b1-994c-ff98a945d8fb\tprivateKey\tMIIEpAIBAAKCAQEAqvSJtDDzxWVwNB43i9lGmqVxkDr6TsiTYH+kUBUOKszRAn7dE1uZnVEm1uxwQS9NDyYzECbBPZpjWsG4Zkr9OsRAqldSDivadYXfUuvnJBpqtM2xsktqdxoVIKgHyrpQqG740HDQA2Y2x79dkF7CxFoTprG6i0ECMFWZdO2lDBLP5lDlvWvRCxVZWTjzMOybzj0fWA3KOCS8Xd8/6JwD/TPTnNGpWJ0wbon7myfg9ZtXtezBVghmhOxJc6mIBRDXO4+OUm3YT5cuHJm5oixd0iM2nzZ+MgGt+HrJH8WpiURxo2cRbViEde8EskXZ0GZNsrFmrzkLkp4NkCeXn0hGhwIDAQABAoIBABV9iSQANVy+rDS5CbWIWkJNgvGlIFswBqrOUOsKQo4p0ip0pTjuPmjSz0WcUl43YoIBhNDGdmtWkZ/Sk2o0ihHNou1m7bc/VwaIDjNf2V4l/fz9kJV/uoH/YuGIjfYzprkNvjSBwfxzaHaCvXHNH8HMKwU8+VzRDsPhttlGmIVkUQqsauDMnbGY5/sM26EUFOW1PtnZfClzKEHTjy9K4dkdkfl013TPEJWL53218oeSYCh/5rlmGhHyKFUtdjFpenY8ww7JUQ0+BYpyOKdUbFmNF55njTqIpoisumGvYhtPuXeRGNemcG+A9yGrfxQj+Q9qcWxEHYg2wJePy58fo7kCgYEA669FpkHDo30VhT/ss36qMTEzpGvL8zE5TDf1e89LaUZ2KSemyJblwMMJd1gFpz0qI/FmWqrKYEkiu+iBkCwiQKpZirFBbwpOPqna6dYEBfQx+3Y1DOwxP9AVJrZIkKd2q0efNZWS1q/NDp6zO0ayQFI0vQcDm9RuWFTGdIinhqkCgYEAubDqeq/kUjcFa1nbucVg8nFX1Jkv3Q6tnFXVmVCroVEp7G0YFMTumF93GaUkTMiWf7sjkIAOLASAEhfdJZtyj81Q4jlUUJSw9gVziGODyYzzYqueSEyrb2AGs+jCCl1Xm9c7897hGT5r9XdgkUffTO0IZ+Ic/OJ0un06WfuCEa8CgYEA5kEfP5WCZ8f9bWgNfTMjXNnfxSPXZilR/CezehkEaL6BFCX76H6byd4B8omZRFEaSXE++Rdgjf8FoyU85zhm4lxLDJeuAKjF1qylBcyjs5ll93D91TkvyFMnRuHRNRmWczGO1o/hnEK2iDD9k8y2uuZVRdODcAtzHnL8S79yQ7kCgYEAiuozQD09zdOjlj/PBRcIA9ePIVjIWdOVRZNhDCUxgKk7d8fVcBQzeoJQkzrcASU+kafqXzutXnivZtm7c1rrRXEPxW2mCfJywForqCjqemmb2oERFH1m8xcfbJAAOcjCta87BqICO+Ra13PLJmRiRSY+V5jsnrK7KJhugsqI77kCgYBSMmNJkUqag5+waS7xpCV5/3QQrZBJKeIlSj7TblDudn4DSoJpAMic+SCNDaAvvIkSp0gL+9BpWCcsgbhPSGR19zccPLiSH2QyAxOyfSWzi/Yz5nl6VGO2GP/F91nI5Jo14nkrE5VTAswtcunu1btLoIp23vzzPx9y2pebPCNtZA==\n9b78a5b5-b6f6-4326-b734-a42abd74e035\t901a3f02-9c01-41b1-994c-ff98a945d8fb\talgorithm\tRSA-OAEP\nc9e37d91-4576-4448-8143-89c56e1a4192\t81a8b0c4-001a-440b-9cc7-6ae00fee5cab\tkeyUse\tSIG\nc269d463-3b58-4f32-9b65-3d8f659460bc\t81a8b0c4-001a-440b-9cc7-6ae00fee5cab\tcertificate\tMIICmzCCAYMCBgGLfBd1yjANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjMxMDI5MTUzODE0WhcNMzMxMDI5MTUzOTU0WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDbAM3oDQ7auBcT7c16bweYnfkUkGxOi44mAsvq6TF8erLAmnvV5Fdn9U9ZNHhsVwD5YaeugNy0NrEVE8wyjUSm6tyBbTGVpnqi+Q8VNKWIhlozvr+oKAa7hPzeRlUlsNV92X7trWp/kiBBNEI65jhQhGVIrvBGgO1j0e16DANFydzujDXlZRauOKSjP8j9JlXxXME+VILtCSOoI/DmuQC4LlqozB8Zd3wRLzFPGGgVUt0flUOBOoap9B9F0FbP6em3eibm0HJQ9MC5UdxI1KadXjqeMPGwJsUYOfeZCxpMVMxro4jhcqtlGEPiVnhZoZ86wlfztdbetu7/ywz4OcBnAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAEEI3l/9akE4bNg9j9WrvkdcuyPjWd/nUFdIcCqqNRJIG6EmbUNBeYYJZILew3vvjBmCnlHwuKvstu514DaIaJw3auwCYc0ITQI/iOW53F/gShIUWTWq8YnCqwGd4TfOVxbsdrtBIWznJGmXZ3aZSabJtHdUxfp/F+XpiUD4o/kqDtW0q0+CI58CKO712PABwnd9O3N+KK8Btmu8/yCTsb5dz+mNg1EcX7F4hj1IyZ3lQ+6yUhX9o9ldK/Z/p1P41WBpkEpQKI7pXZBrRH2cFGZOEjQ4N3+B/i1e2SQXkfEh2myR7Cotfg0fPS07TtNCAsbrX9iES+AgS709JGfhtv0=\n51c98b63-20fe-416d-877e-b61805d5b8f2\t81a8b0c4-001a-440b-9cc7-6ae00fee5cab\tprivateKey\tMIIEowIBAAKCAQEA2wDN6A0O2rgXE+3Nem8HmJ35FJBsTouOJgLL6ukxfHqywJp71eRXZ/VPWTR4bFcA+WGnroDctDaxFRPMMo1EpurcgW0xlaZ6ovkPFTSliIZaM76/qCgGu4T83kZVJbDVfdl+7a1qf5IgQTRCOuY4UIRlSK7wRoDtY9HtegwDRcnc7ow15WUWrjikoz/I/SZV8VzBPlSC7QkjqCPw5rkAuC5aqMwfGXd8ES8xTxhoFVLdH5VDgTqGqfQfRdBWz+npt3om5tByUPTAuVHcSNSmnV46njDxsCbFGDn3mQsaTFTMa6OI4XKrZRhD4lZ4WaGfOsJX87XW3rbu/8sM+DnAZwIDAQABAoIBAEnjrhUWXsYlqUekipi94Dq6ReENzzT6+dVSSTmzPuqIPUoldRWX6nOdPQ3UWbL38dCyBZCnUo+NClcZrGH77sdAY3BZhoq+tg7JaDDX5+e1qLZfq6tRAeB/wI8//JdwX7+Lw7ruMQnko/RL7PKRXTuxR7jrqQ6oji+JEw4EApCnKHiC0rmm26oIXbKloFEJRPQF6dWpRH6+Nwb+Viu/x2zV43vNt9htQF8LGlhxMwkKL+Dxrqh5f/wQjN/y2viNficYGDy2e1JSJI6NeRYWZD26FftWttinlQ/GHe4C3BMz+TKegm2kQFI/LRaq6Jmhd/61EUbE7etIluBiXFv3GrkCgYEA9kPSlTxbe3k6r6Ud1GfbGLjZ5ZkBrBEK3K+U5l8W84bz+haxPFtDdtE1+UMZo3gFM7BTtHsV2dXLDF8CYgNrJsU7mQbKLt1VMU5TN7uMIs88ulDgn3CK2ZNOB8FO3sxD5K7UpC1G952Q/7nMFxhWfvjYfv0kF+4Yg/kT2SodmjkCgYEA46kYVI96k+KoLlkxc9rUF+KWwv2A/HO0b/oH27D4wrYzLZGcUpB7V2VdK54g0vTZ67EyMx/SG/xA6bm90twhmWqFpiB1IYl/7nCFz4cOAk6ygOSyDVtif3pZYN5O3OGCUkY6JIkd9EXesnGDxZBMfZ+pvfdEc3fi+TZME7a6r58CgYAEqsBg56XsLx33mi94iQdT7pzihwXLFv+XsxQlUYQD1XjmMmvlcu1SYSCeurDPskSW+C596x8845pXf61x4hKzx2Ubv08xmCw3JP7avYkoV7kU5td67g0TloESEI3IFyLqQI3zFpCTvL60ufauMV3iRiEENxPqCC8awSupH+0zsQKBgQCA5AtSVKkhcQGmu/izjwDlRPP2EXAlfZx6iSRJzTgJhP4UnovSctph8JF/UFdlGBsIxZmWOD3MelSF/xLpfpfHM/fWximKgz5a0HnxtJTJ2aRWNSRZS5PIeIyBu1sK0uHlkrz4UmkTEzux63KfN8MWnH6NfqDSft2SGpuXzayEIQKBgAe2CPlGlfuHcvrhJ1xCbO/oddW5cuLrEEObxME+v57bdHM8jbRnRvKNqKmgazIgBn4A2IIcAwe6N7JlAQ8b97P1fhlzKBjWnOkIpaUBFoBndYoQSTIJ0+NJJFCHVcWzbgiR2hfLlKxl2SdNOS10bHfbgRY4iyZjlKzYhe2tRHBH\ncfaa6f7c-5c4c-4247-90a6-63db00174750\t81a8b0c4-001a-440b-9cc7-6ae00fee5cab\tpriority\t100\nb8205bdf-df19-4b59-b591-4f698629c89b\tf2c10a5d-ccfd-4871-a6b3-63d38dfdc07f\talgorithm\tHS256\ne66d0eb0-3df5-47ac-9893-93f9e620d4b4\tf2c10a5d-ccfd-4871-a6b3-63d38dfdc07f\tpriority\t100\ncb9674d8-90d0-44c8-b68a-567aa2381796\tf2c10a5d-ccfd-4871-a6b3-63d38dfdc07f\tkid\t3c87290b-572c-413e-85f8-f915611ab64d\n59e799eb-3ce3-48d1-93ab-880eeb3e54ed\tf2c10a5d-ccfd-4871-a6b3-63d38dfdc07f\tsecret\tkV9LCO9ob8x1BfJBTko_TQVyNz6JYYKfQNI7h1lJTxMdR-gIzLxsosuNUeSmfyP5nancnVUwUufvH4rhfwG85A\nefabd5b7-9120-4aff-b0ca-c1f0bd12e2ff\t58668ed5-7460-492e-8c0c-bae21e11aeae\tkid\t1a5bbc15-ff6f-4c8e-b787-553186a10bf1\ne974071b-f00b-4fcb-8921-32a0b2c208dd\t58668ed5-7460-492e-8c0c-bae21e11aeae\talgorithm\tHS256\n5248d868-95a7-41ab-9cc9-a07f0029b0ce\t58668ed5-7460-492e-8c0c-bae21e11aeae\tsecret\tUWp19a7p7ORAxnohi7wL7c5t-YI5EmSDKDx7ri2omGyeHrxqzTlnTwWejKtN-qtHQHHpcCN6qDe8FYP0t7Po0Q\n7c25d28c-9ccd-436a-922f-bc43e3ffb193\t58668ed5-7460-492e-8c0c-bae21e11aeae\tpriority\t100\nd240f4f9-dbcb-41c5-ae7c-0ce434dd4e8e\t1f84d56e-1fb0-4e65-9e2e-61bee1bb8b5f\tkid\t6ee46ffa-821d-42c5-afbc-e3a37b5514a4\nf314aa80-fff1-49be-baf2-3e532de318da\t1f84d56e-1fb0-4e65-9e2e-61bee1bb8b5f\tsecret\tIOMW2I5Jm8AM70W0LXv_Eg\nabc8653b-2f67-4afb-8b36-774aa0885003\t1f84d56e-1fb0-4e65-9e2e-61bee1bb8b5f\tpriority\t100\nc23ff207-71b0-4049-acc8-289cd8fea124\tbb53904f-cdd6-481b-9379-2aa79eae2100\tprivateKey\tMIIEowIBAAKCAQEAvJpZ4qRHaghc00mgcD7rad6uCla6BhHrQiaO3YdRaMGkTHl0YwgOkzYS9Y61Khd4gPmRhMYxgl35GSlAkFdACR7t8qKz+aypsH3xeqMB9b6QfADVMyn05Ir/DoR5Vtjc6v+nv3e5ufW214/VOmFIZ0scUH4/GxtctjuztmeBczhDWZ/XeofRPb27ouzl/Fr5RgjFmnxav4Iw+BGn9uByLrVhE8ytTCsfp1dskRt1ovCNN8NjaV/RdbGHObQuLrjC5zmda8jzGgtl0CLBDUXCJcfxRvBcA+HOinTsyMsrEGjaCD/dpYxxj1tCmSiqu8rUJDGzqEno7XvejdkZLGnnFQIDAQABAoIBAAmkAjA5oYTkBIqwWskiDjqNLWxN3phs1g+9lNPyFNE8BL/7/V0KjmQsAXAX6V7LcFd3al7VGrbFQvRsTWaTbyyILjWW54g9sTbaWTuhlXoQUaZlDIDfBiugh0UgtGsiDrjcdKCu6Al/a+c87PNdAax2BG6A5YznKygNiTQrukw/X4DjU51kkeSy72uKEXyQgE/E6QzcOBfGiZyTcYgy7KkW3p1CDVUYnThB9Fl+IE1O9/WV0jaAZsrO4mtTQI4y02AIDJHw3y5dvPjyqu4egTYYF9GcccFu6sIo/lu8lLdlphFWZCCv5ZXJYK90EEtpxPKfA74l+j0Gg0L7zegJg4kCgYEA6ULc30VgYWiK25TxyIoi6f1YVEAPfl3iwkz/hnLB39e3Io/Y90N01IfAxfyP7Vcyre9BxN1uMcZX7gQlSov5PGRW+EZNKHbCh5zcZeoVvkPccf9Npn/j6tg+4vmdLtnxIKeeb7yXMkSsI0A8Xhbu7J5EZ3R+V/v0JQTxggclFDcCgYEAzv0FkUtNFskPtsCvYgVmm5EKP2S4mDeLdSe0SA7cBbrUsgywaw6GIENDJ+vuR7CEJ/2iMFPOX2bHFoTAkekZlAxUJ/mCbjr6+OTlxejY8y15VeJJCvXNlCOiW/qqgwQBAvgOShBjRKpJJRdzuy8rqDgi6kxsUeYClTLG0MbcURMCgYAmDT1Awu2FFmvIhFSo9Tfa2fRF0il04NX0AmGQyjmsTWFXpwWq2Hs/jGG7KodEHXxr+WLOPZ0TS5refhijP5BJ9MhnOfiuSClVvBYMHhKr9iAJDK/bIHPKxLoFhtjIYs8+F3n2GlrD3YYDPiBa7PzO5sab5doSekyKmXLYVlgLIwKBgDr4+80Zly0Wu9NlspJK16EbAcBuAencaW9HkKW3FhjL0i2oT9swmCY5A7ksDwd90ylRqhP6zKGBttdDm1n2/8KegJujCvY896RSEuUAIk+mdRtzDTyCK8A5Jtjt4gbR7TfbVLblVGML4SsgM2jxV47l74yxmWr8DWBUxzBUeBDhAoGBAMx4O0/nCLFAnHcNg2ao+sUfVaasbiWFrN2eRLODY8vC+xCKiDzLn28Av+gwyrwxxPRI7D8VVBnUMkdsNT6cbf37mIya5Na6vjoKep7EPrsSeo8R0aXbwvjFzLmkLmtL2nfl47fwl3SsyuWygQ/90r9AUZx4VSUblkqghorZhv6D\n4c8ed167-dacb-4e93-ba58-bec258c868d6\tbb53904f-cdd6-481b-9379-2aa79eae2100\tcertificate\tMIICnzCCAYcCBgGLfBfmATANBgkqhkiG9w0BAQsFADATMREwDwYDVQQDDAhvdXJib2FyZDAeFw0yMzEwMjkxNTM4NDJaFw0zMzEwMjkxNTQwMjJaMBMxETAPBgNVBAMMCG91cmJvYXJkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvJpZ4qRHaghc00mgcD7rad6uCla6BhHrQiaO3YdRaMGkTHl0YwgOkzYS9Y61Khd4gPmRhMYxgl35GSlAkFdACR7t8qKz+aypsH3xeqMB9b6QfADVMyn05Ir/DoR5Vtjc6v+nv3e5ufW214/VOmFIZ0scUH4/GxtctjuztmeBczhDWZ/XeofRPb27ouzl/Fr5RgjFmnxav4Iw+BGn9uByLrVhE8ytTCsfp1dskRt1ovCNN8NjaV/RdbGHObQuLrjC5zmda8jzGgtl0CLBDUXCJcfxRvBcA+HOinTsyMsrEGjaCD/dpYxxj1tCmSiqu8rUJDGzqEno7XvejdkZLGnnFQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBbFcIqmRCDnDRPSutSYe1eb62kX7XnUtuIJIaalo3bC4veq+zejL96Iw19FLm8LdtlvQxXoNneAqIcAnHm+pOnxVtjOekQe7A0CPF3fh6kFw7X80a/lYg//6cC5veKZvJm6H1C0xEfOxRd2smIQ6sx4gdNP5b6D1beiGT7sRP5/pgXU1SewPJ6s/tJwkIWWsL0oKNJXjwXHhXpYN/1yxZoJE6obucyEMzgKABZM474E6EvZcuMeyWrf2EXmJxTCpQJ8l/l7P7h1/+QMhyiWOMJwOTsA0hS2kEl1ALnqo89NPuize847sVZQDBL7y0afGUSxFuSNqCAr20wh0veBnwR\n64a54ee7-e3af-49e9-8351-95cec08a2902\tbb53904f-cdd6-481b-9379-2aa79eae2100\tpriority\t100\n8ae0689f-1e89-4458-a9fb-fab351f86320\tbb53904f-cdd6-481b-9379-2aa79eae2100\talgorithm\tRSA-OAEP\n8616739a-8d92-4a25-8758-450c8a537111\tbb53904f-cdd6-481b-9379-2aa79eae2100\tkeyUse\tENC\n87cd1a71-e499-4ab8-a829-4c5f8c971e65\tfdc0d5bc-0668-4fda-838d-73fb8240c295\tcertificate\tMIICnzCCAYcCBgGLfBfluTANBgkqhkiG9w0BAQsFADATMREwDwYDVQQDDAhvdXJib2FyZDAeFw0yMzEwMjkxNTM4NDJaFw0zMzEwMjkxNTQwMjJaMBMxETAPBgNVBAMMCG91cmJvYXJkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3etqvfHQAMBspYIiCYDEFcO2nJoCB8DqR/w5fac/8UYzI/Dd+0y3q7pPWGWlwhYV/A0Xe9A25CTf/qV6bCPDOw9Re3ZXhiKeqfFkoq1bISTXf4kP2xgF+HAO+u471F5W/IqhaoI2wwvwXHQr/lJJoKDg+SsNmdbzz1TwEL5zpHWq5YjFZrIVuEsCdVlYzsc6jVZEzBsnZkyYtqCwlYHJwd15LVaugWOQkTpCuysb9NnRuGOupuih36K7k7Pk9rsqBRfKlhqQLDWRhn5a74lrVOlaW8fCkerhoVdi/UxjPTADIUbsUHs73n/Nrv0FaRKA9WdVBLegmKItZPXhJFmtMwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQDCTPr8cz6qd77ac8dLvdQPXbX9+St9odE10RcDUtPAbkxf7FFWOatAYwDNX56G2NrJX0mKKtsJKg8M+IUQrE3lgi6dYevdmQ3YxjY/ZW+FH36XUCaAwlJbPsfRYoxuNNCqf13F3SNRZj/RXpsS5u5hdvD0dzzpBDzAT0NpVS8geUaLagfQaGNML3z3YJgiaXD/+/nA9Oc/C3WsrCrsJxV/NQGelF+tOxrm9U8TZdqoO8G/XFoaAP+hNxfeKJFiksDRGPQ4k/v90tZoshOH2xnnmMdsxeg3K8FEFb7TEjkegeWJl1ZXpnOe7IfxVKH3r+qrbR9LYPfFLA9I4MjRy9JB\na9ed6d26-bfa9-4129-b711-efdce88bfcea\tfdc0d5bc-0668-4fda-838d-73fb8240c295\tprivateKey\tMIIEpAIBAAKCAQEA3etqvfHQAMBspYIiCYDEFcO2nJoCB8DqR/w5fac/8UYzI/Dd+0y3q7pPWGWlwhYV/A0Xe9A25CTf/qV6bCPDOw9Re3ZXhiKeqfFkoq1bISTXf4kP2xgF+HAO+u471F5W/IqhaoI2wwvwXHQr/lJJoKDg+SsNmdbzz1TwEL5zpHWq5YjFZrIVuEsCdVlYzsc6jVZEzBsnZkyYtqCwlYHJwd15LVaugWOQkTpCuysb9NnRuGOupuih36K7k7Pk9rsqBRfKlhqQLDWRhn5a74lrVOlaW8fCkerhoVdi/UxjPTADIUbsUHs73n/Nrv0FaRKA9WdVBLegmKItZPXhJFmtMwIDAQABAoIBAEf5+vzwWQ5lWtezhWafpPmKKMhSCyaIo3QFkn/2pv6STjPyA41mLIw+OU3qagCxOSAu/tbhiWwlqaDl+vboAoI9TahFkc2nnN5xtLouj8gIMvzib4oYGJejgLhSXIVcs8nlDMWGxkwsqyDRbRNnjQnUJCOsmxSyaxFr8xD7NHa6OLyF15tGIYm6f5eeYniSKYs8BqiTDp5CKgprN1iyVqAMV0pYo/QSvh6mCNO3KSGN7FlN/KbE0JWe7+E7EctGjDPXHBnANcXMKJkaeEIwOgyWZbFF/NP6VZSFmifIUq94O+lzbgJ0Qyp8SPXrbkysa4gs5LKCefTEoyZuZfgMbIECgYEA8J7Xu5UjF5CUngCCSxHcn6Gm19DQQH8aLc4r6RJfe/z26+anXpcLaNT3IU8nnSJqM6XOwsZ/tQoYr9qUlyZDrARZSo7ULXdhOHd/OF6q/eE/9zWhMcR2sAr+XJbnTJWermZyTcNyT9S0ZoHxJWDYledP/3Ziv5cVbJEDFS03iTsCgYEA7BqUqXS0gtRrCAgutNH2C5WUCisPqCn1IsiY/EO3RJZUAzkyXh5K1SW28t05ZIKS6BP/s3R7JJdNqjc1PDPMeoFtR0SxoMd/e8b8nRDpQ5QHluR+EtmzSKDoaJyDaKZ5Ap60WU0gv7uQx/r8/IzdzkmhP2cgmpV2f16sN1Ew7GkCgYAPRgXrokvX8xV78gxTN48JkvlEObz+WxMOVUf0Q3ZMKIKD9uAo4O2Yeew18RBSqRyUqrG1K2Rv2XQ3tWg5L/Sbtqr0UJynRiylqPAqY9f2xZWJ252fyxi9k/URa4LDGbw41cfrp7xZ1OaemyDzfnJBEa3CSYF7J9v0SEAp1/TugQKBgQC2DdDv0WOXNf/J07VgDD3ytMXQCWArRR8WUSNV8UkRg/EIA0SJOkZtkIU/Q8ILdHuepD0YvQpvLpPeWm+cGjzjgYUn3RoyZWIxqUAERJP5Xd10Rn/IPUF3EUvjzjutqB/LG2DpMwW7kf+TlD8a5evqMvA5GWYUjIcws2mLxfk5cQKBgQC6E61nCpuqEklAJFgXa9szyCvNo9JQxkcicF4Ut0y044bXzISTSuuetORlel0G+69UmjCfyosU3orSzBne77x8zlHaqcyTMItaVgqc6XjmG8DGKNHjhFJ23vK++J/SJekWJT44QvE20SSWQvPVK8Z/fK9/bhlwR1de8sGfKijpdg==\n6cebc48b-f81b-496d-94c5-d4dc124b8e61\tfdc0d5bc-0668-4fda-838d-73fb8240c295\tpriority\t100\nb11f7bf5-66af-4dc0-be0c-10df26b8442d\tfdc0d5bc-0668-4fda-838d-73fb8240c295\tkeyUse\tSIG\n66cd8ed7-d956-4cf3-9afd-3f5a004a47c3\t9853f82e-afb6-4537-ba7c-e1644b4dae3e\tallow-default-scopes\ttrue\n4b51f876-2efa-410f-9022-b8268dda487d\t8b8026ea-2fe2-48a4-92c7-8282d292f7b0\tclient-uris-must-match\ttrue\nafb2a75f-901f-436c-8edc-e94556f946c3\t8b8026ea-2fe2-48a4-92c7-8282d292f7b0\thost-sending-registration-request-must-match\ttrue\nafecdefa-ac6a-4b7b-a5ee-093647425998\t8fb797ed-1282-4850-b960-adfb28daf4a6\tmax-clients\t200\ncfc26032-42a4-4655-a721-af52c9af4f09\t7a4f8643-ecb5-44c9-884b-40ce6c215bb1\tallow-default-scopes\ttrue\n90451b84-b807-42f0-b19f-2edc70c1eb0c\t6d9d2d89-897a-427a-8b9c-04d0448419e5\tallowed-protocol-mapper-types\tsaml-user-property-mapper\n1d4484bd-f4dd-43f6-8a7f-d0bb9c5f952a\t6d9d2d89-897a-427a-8b9c-04d0448419e5\tallowed-protocol-mapper-types\tsaml-user-attribute-mapper\nf2e19b73-5a9d-4da4-a29a-f988e76fd375\t6d9d2d89-897a-427a-8b9c-04d0448419e5\tallowed-protocol-mapper-types\toidc-sha256-pairwise-sub-mapper\n89677bcf-7cef-4ce0-a8cf-36d35eabda23\t6d9d2d89-897a-427a-8b9c-04d0448419e5\tallowed-protocol-mapper-types\tsaml-role-list-mapper\n644804f2-b488-4d27-b3d8-15fa8d1ae5cd\t6d9d2d89-897a-427a-8b9c-04d0448419e5\tallowed-protocol-mapper-types\toidc-usermodel-property-mapper\n26fc83dc-daa2-473f-b00b-d602925b6d33\t6d9d2d89-897a-427a-8b9c-04d0448419e5\tallowed-protocol-mapper-types\toidc-usermodel-attribute-mapper\nc221df07-dc21-49f6-9b7d-d1e1ce98f80d\t6d9d2d89-897a-427a-8b9c-04d0448419e5\tallowed-protocol-mapper-types\toidc-address-mapper\ndfb98deb-6732-4dec-96ee-dacf03ef3c63\t6d9d2d89-897a-427a-8b9c-04d0448419e5\tallowed-protocol-mapper-types\toidc-full-name-mapper\n829e79e2-6d75-4ea3-afb2-6926a1675567\t94a0b3de-82e6-47fa-95f5-113b4c830202\tallowed-protocol-mapper-types\toidc-usermodel-property-mapper\n6a7161db-7715-44cd-a3c6-01cb8b6f50e1\t94a0b3de-82e6-47fa-95f5-113b4c830202\tallowed-protocol-mapper-types\toidc-address-mapper\nc57e4d4b-7906-4a00-8e01-f0dce074b70f\t94a0b3de-82e6-47fa-95f5-113b4c830202\tallowed-protocol-mapper-types\toidc-usermodel-attribute-mapper\n55a51574-6699-4a35-bbe9-43216325fe53\t94a0b3de-82e6-47fa-95f5-113b4c830202\tallowed-protocol-mapper-types\tsaml-user-property-mapper\n0fa0ae09-d661-4e0e-abf8-d8ba87c2b39c\t94a0b3de-82e6-47fa-95f5-113b4c830202\tallowed-protocol-mapper-types\tsaml-user-attribute-mapper\n4c4ecf59-4fe5-4aa6-b45c-5bf7443ed5e9\t94a0b3de-82e6-47fa-95f5-113b4c830202\tallowed-protocol-mapper-types\tsaml-role-list-mapper\nf7ede4b1-d6ec-4e8c-a3ed-8a441fc0b8e1\t94a0b3de-82e6-47fa-95f5-113b4c830202\tallowed-protocol-mapper-types\toidc-sha256-pairwise-sub-mapper\n0425606e-e1b3-4a37-8deb-de45f0e30634\t94a0b3de-82e6-47fa-95f5-113b4c830202\tallowed-protocol-mapper-types\toidc-full-name-mapper\n\\.\n\n\n--\n-- Data for Name: composite_role; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.composite_role (composite, child_role) FROM stdin;\n46550935-9d8b-463d-acb7-76e5914619fc\tfd39b5c8-cb22-4300-8360-90b1e78b203f\n46550935-9d8b-463d-acb7-76e5914619fc\tf1393577-a95d-4b9c-bdb5-acbb7659ec49\n46550935-9d8b-463d-acb7-76e5914619fc\t3a797a27-ced8-4019-b89c-be3f96768fef\n46550935-9d8b-463d-acb7-76e5914619fc\te07412c0-ef2c-45c2-ac71-9b5bca3a1d0e\n46550935-9d8b-463d-acb7-76e5914619fc\t9000313e-62c1-4d0f-8225-74cb6271bad0\n46550935-9d8b-463d-acb7-76e5914619fc\t8dc00b7f-a0e9-4d6e-a0a8-6792164c82d6\n46550935-9d8b-463d-acb7-76e5914619fc\t4a1f2e56-820e-4c65-af39-74f9fded83e9\n46550935-9d8b-463d-acb7-76e5914619fc\t805f4dd7-9b86-4677-91b8-827b14eb31ad\n46550935-9d8b-463d-acb7-76e5914619fc\t3f88eb66-8324-42bd-8b5b-1f51b3a717b1\n46550935-9d8b-463d-acb7-76e5914619fc\tbd4d1300-f534-4e16-9310-559b9109fea7\n46550935-9d8b-463d-acb7-76e5914619fc\te692941f-c589-49f7-aa22-e7b8f298a3a8\n46550935-9d8b-463d-acb7-76e5914619fc\t95a46663-9356-402e-a991-98f432ff5079\n46550935-9d8b-463d-acb7-76e5914619fc\t6ca48090-3d61-49aa-bc01-e20c94dbb8d0\n46550935-9d8b-463d-acb7-76e5914619fc\t0f34eb53-edb3-40a9-b861-11e1eff1935e\n46550935-9d8b-463d-acb7-76e5914619fc\t1c4d9801-19ee-4987-9ef5-1c50e1aeb05e\n46550935-9d8b-463d-acb7-76e5914619fc\t6605f11e-6eb5-4cd7-8523-be58dbb503b0\n46550935-9d8b-463d-acb7-76e5914619fc\t0584c7d0-dc16-493c-9b26-347920d2ffb1\n46550935-9d8b-463d-acb7-76e5914619fc\t08d7b65a-b41a-458d-b7c5-6d2757965f3d\n1102e6ef-567c-495f-9704-609b8439f6ed\t79c6e296-dba7-4181-9736-42d4fd1f83a1\n9000313e-62c1-4d0f-8225-74cb6271bad0\t6605f11e-6eb5-4cd7-8523-be58dbb503b0\ne07412c0-ef2c-45c2-ac71-9b5bca3a1d0e\t08d7b65a-b41a-458d-b7c5-6d2757965f3d\ne07412c0-ef2c-45c2-ac71-9b5bca3a1d0e\t1c4d9801-19ee-4987-9ef5-1c50e1aeb05e\n1102e6ef-567c-495f-9704-609b8439f6ed\taa935992-177c-4b37-baf7-f5f8ae32468b\naa935992-177c-4b37-baf7-f5f8ae32468b\t4fde5f2b-6ca3-4c7a-b5e3-1df000e4ecc2\nb528aaa9-1cad-45c0-8e91-79052fcf9222\t25529f2b-472e-4967-bd1d-8ca4621d7c73\n46550935-9d8b-463d-acb7-76e5914619fc\tbc3af868-084b-4d1d-af35-c3134c13de2f\n1102e6ef-567c-495f-9704-609b8439f6ed\t418f571e-3a36-4904-800a-c31d10ab4194\n1102e6ef-567c-495f-9704-609b8439f6ed\te6b153a5-b02e-41db-97d6-2001abf16783\n46550935-9d8b-463d-acb7-76e5914619fc\t247b42fe-6f30-4b0b-b5ac-96447df756b8\n46550935-9d8b-463d-acb7-76e5914619fc\t2193a964-a5d7-4e85-8c35-0f3cb7bc9682\n46550935-9d8b-463d-acb7-76e5914619fc\t4904d33e-7188-420d-a264-93f17fd58095\n46550935-9d8b-463d-acb7-76e5914619fc\tfd88bb53-f80a-44ad-ae72-2368bf56691c\n46550935-9d8b-463d-acb7-76e5914619fc\t87c8788d-9c09-4687-a548-80a4202e33f4\n46550935-9d8b-463d-acb7-76e5914619fc\t341e9c39-5731-47dd-9c83-a4f94b099a3d\n46550935-9d8b-463d-acb7-76e5914619fc\t68244192-cd4f-4cbf-8abd-e0ed5f73bf49\n46550935-9d8b-463d-acb7-76e5914619fc\t8405b1b3-91da-43b0-a71c-af5b22e3d643\n46550935-9d8b-463d-acb7-76e5914619fc\te4cd51e3-0628-4992-a027-be78a0b6c5b8\n46550935-9d8b-463d-acb7-76e5914619fc\t2d632b33-d875-491d-8e12-e6cf68f117f2\n46550935-9d8b-463d-acb7-76e5914619fc\tf1e68c21-ffd9-4154-87ac-a25175432022\n46550935-9d8b-463d-acb7-76e5914619fc\tc3713905-4f3d-4a0d-a564-233ebaf7c0c8\n46550935-9d8b-463d-acb7-76e5914619fc\t90b14ddf-7076-4f7d-a398-fc284b8fb064\n46550935-9d8b-463d-acb7-76e5914619fc\t322e8bdd-eebf-4236-8256-acdf1e06199d\n46550935-9d8b-463d-acb7-76e5914619fc\td5a944ea-ca53-4b24-8a5f-3213c5ffe7ea\n46550935-9d8b-463d-acb7-76e5914619fc\t3b54e2ac-8edc-4c20-a3e2-52a261118492\n46550935-9d8b-463d-acb7-76e5914619fc\tfb6f79cb-d573-4829-9d60-b92ea98099ef\n4904d33e-7188-420d-a264-93f17fd58095\t322e8bdd-eebf-4236-8256-acdf1e06199d\n4904d33e-7188-420d-a264-93f17fd58095\tfb6f79cb-d573-4829-9d60-b92ea98099ef\nfd88bb53-f80a-44ad-ae72-2368bf56691c\td5a944ea-ca53-4b24-8a5f-3213c5ffe7ea\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\t5131786f-fd1a-4d8f-b7b3-01cc1a36bf8b\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\t763fb3a1-85cf-4c38-9554-305c5be95f87\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\t1ef446fb-3edc-44ca-9f2a-96884538ac6e\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\tca78f9aa-b871-43c9-8ca0-6323d0780ad7\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\t68c8ff41-7fab-413a-8cdd-c270aad522fa\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\t62aeff5e-fe84-4276-b1f0-a4caa6cf65f0\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\t4d066f62-161a-43f6-b2b7-1de1092eeba9\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\t1ed81f82-b318-4314-9485-7f27248ac479\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\tc310e7ff-7942-4ab7-9771-e340dc76741d\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\t7da5daec-6a08-4529-b433-c304b410dd97\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\ta7341a81-3a44-4e53-b7bb-7125b465b356\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\tbd90e0ee-ad9e-4fd9-a8cf-402fc6f01bf0\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\ta1a0faf6-74ec-46d5-bf3b-cad0e56d6eec\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\t62ec5875-b826-47c0-a4eb-2a2b768e36b4\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\t0430e54d-8bc5-44c9-b0a3-e29e9ff5b425\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\t536dbf51-41d0-4b93-9a18-eb0bb5522467\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\t5c850b9c-f0ba-43f8-bea4-2b677a5d1a23\n1ef446fb-3edc-44ca-9f2a-96884538ac6e\t62ec5875-b826-47c0-a4eb-2a2b768e36b4\n1ef446fb-3edc-44ca-9f2a-96884538ac6e\t5c850b9c-f0ba-43f8-bea4-2b677a5d1a23\nca78f9aa-b871-43c9-8ca0-6323d0780ad7\t0430e54d-8bc5-44c9-b0a3-e29e9ff5b425\nd18c7a1e-954a-478c-84cd-ab60ac89c351\t1549050d-2d90-44d9-ac44-1bca24b71a9f\nd18c7a1e-954a-478c-84cd-ab60ac89c351\t85690fb9-8525-4ce3-a100-ab59e38f6d5e\n85690fb9-8525-4ce3-a100-ab59e38f6d5e\t9fdfe2d2-a446-4708-9962-99e7ba60a838\n3edda74a-512a-4b3d-b00d-86b9dc214a78\t8c2afacb-186e-4df8-ae37-775457dfb6cb\n46550935-9d8b-463d-acb7-76e5914619fc\tbebfd4ef-d674-4d74-b332-026e52494fe5\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\t9e53154e-c887-4a26-bb7c-c76b06dc0656\nd18c7a1e-954a-478c-84cd-ab60ac89c351\te5ad2c92-6fec-46ff-ac4c-0c519483a403\nd18c7a1e-954a-478c-84cd-ab60ac89c351\t62dcc982-a8a1-4b37-ac34-c5d82842548f\n\\.\n\n\n--\n-- Data for Name: credential; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.credential (id, salt, type, user_id, created_date, user_label, secret_data, credential_data, priority) FROM stdin;\n4240a5c9-08c3-41e6-b141-5506c06c2f26\t\\N\tpassword\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\t1698593994730\t\\N\t{\"value\":\"WDq1aa09OgiUxaM0S0v2DBaWyO/k+74WsTOBbfIld0c=\",\"salt\":\"6A95mjKEh7KPGHsh6WqZjQ==\",\"additionalParameters\":{}}\t{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}\t10\nd63f0fb4-3721-4ca2-94b5-7dbccc3dd2a8\t\\N\tpassword\t32796b05-317e-4469-8b52-549ce1d0745f\t1698595431887\tMy password\t{\"value\":\"/QpOGKkBnI3rwQYaWLjBf4bZmryyB+GhK6jTTb6wfkE=\",\"salt\":\"ew6wM4wBCaMUBB6d+3Wqig==\",\"additionalParameters\":{}}\t{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}\t10\n\\.\n\n\n--\n-- Data for Name: databasechangelog; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.databasechangelog (id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, description, comments, tag, liquibase, contexts, labels, deployment_id) FROM stdin;\n1.0.0.Final-KEYCLOAK-5461\tsthorger@redhat.com\tMETA-INF/jpa-changelog-1.0.0.Final.xml\t2023-10-29 15:39:52.526122\t1\tEXECUTED\t9:6f1016664e21e16d26517a4418f5e3df\tcreateTable tableName=APPLICATION_DEFAULT_ROLES; createTable tableName=CLIENT; createTable tableName=CLIENT_SESSION; createTable tableName=CLIENT_SESSION_ROLE; createTable tableName=COMPOSITE_ROLE; createTable tableName=CREDENTIAL; createTable tab...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.0.0.Final-KEYCLOAK-5461\tsthorger@redhat.com\tMETA-INF/db2-jpa-changelog-1.0.0.Final.xml\t2023-10-29 15:39:52.55707\t2\tMARK_RAN\t9:828775b1596a07d1200ba1d49e5e3941\tcreateTable tableName=APPLICATION_DEFAULT_ROLES; createTable tableName=CLIENT; createTable tableName=CLIENT_SESSION; createTable tableName=CLIENT_SESSION_ROLE; createTable tableName=COMPOSITE_ROLE; createTable tableName=CREDENTIAL; createTable tab...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.1.0.Beta1\tsthorger@redhat.com\tMETA-INF/jpa-changelog-1.1.0.Beta1.xml\t2023-10-29 15:39:52.582181\t3\tEXECUTED\t9:5f090e44a7d595883c1fb61f4b41fd38\tdelete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION; createTable tableName=CLIENT_ATTRIBUTES; createTable tableName=CLIENT_SESSION_NOTE; createTable tableName=APP_NODE_REGISTRATIONS; addColumn table...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.1.0.Final\tsthorger@redhat.com\tMETA-INF/jpa-changelog-1.1.0.Final.xml\t2023-10-29 15:39:52.584582\t4\tEXECUTED\t9:c07e577387a3d2c04d1adc9aaad8730e\trenameColumn newColumnName=EVENT_TIME, oldColumnName=TIME, tableName=EVENT_ENTITY\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.2.0.Beta1\tpsilva@redhat.com\tMETA-INF/jpa-changelog-1.2.0.Beta1.xml\t2023-10-29 15:39:52.639856\t5\tEXECUTED\t9:b68ce996c655922dbcd2fe6b6ae72686\tdelete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION; createTable tableName=PROTOCOL_MAPPER; createTable tableName=PROTOCOL_MAPPER_CONFIG; createTable tableName=...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.2.0.Beta1\tpsilva@redhat.com\tMETA-INF/db2-jpa-changelog-1.2.0.Beta1.xml\t2023-10-29 15:39:52.651473\t6\tMARK_RAN\t9:543b5c9989f024fe35c6f6c5a97de88e\tdelete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION; createTable tableName=PROTOCOL_MAPPER; createTable tableName=PROTOCOL_MAPPER_CONFIG; createTable tableName=...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.2.0.RC1\tbburke@redhat.com\tMETA-INF/jpa-changelog-1.2.0.CR1.xml\t2023-10-29 15:39:52.713983\t7\tEXECUTED\t9:765afebbe21cf5bbca048e632df38336\tdelete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete tableName=USER_SESSION; createTable tableName=MIGRATION_MODEL; createTable tableName=IDENTITY_P...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.2.0.RC1\tbburke@redhat.com\tMETA-INF/db2-jpa-changelog-1.2.0.CR1.xml\t2023-10-29 15:39:52.729492\t8\tMARK_RAN\t9:db4a145ba11a6fdaefb397f6dbf829a1\tdelete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete tableName=USER_SESSION; createTable tableName=MIGRATION_MODEL; createTable tableName=IDENTITY_P...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.2.0.Final\tkeycloak\tMETA-INF/jpa-changelog-1.2.0.Final.xml\t2023-10-29 15:39:52.736454\t9\tEXECUTED\t9:9d05c7be10cdb873f8bcb41bc3a8ab23\tupdate tableName=CLIENT; update tableName=CLIENT; update tableName=CLIENT\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.3.0\tbburke@redhat.com\tMETA-INF/jpa-changelog-1.3.0.xml\t2023-10-29 15:39:52.792259\t10\tEXECUTED\t9:18593702353128d53111f9b1ff0b82b8\tdelete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_PROT_MAPPER; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete tableName=USER_SESSION; createTable tableName=ADMI...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.4.0\tbburke@redhat.com\tMETA-INF/jpa-changelog-1.4.0.xml\t2023-10-29 15:39:52.824703\t11\tEXECUTED\t9:6122efe5f090e41a85c0f1c9e52cbb62\tdelete tableName=CLIENT_SESSION_AUTH_STATUS; delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_PROT_MAPPER; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete table...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.4.0\tbburke@redhat.com\tMETA-INF/db2-jpa-changelog-1.4.0.xml\t2023-10-29 15:39:52.831098\t12\tMARK_RAN\t9:e1ff28bf7568451453f844c5d54bb0b5\tdelete tableName=CLIENT_SESSION_AUTH_STATUS; delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_PROT_MAPPER; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete table...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.5.0\tbburke@redhat.com\tMETA-INF/jpa-changelog-1.5.0.xml\t2023-10-29 15:39:52.841538\t13\tEXECUTED\t9:7af32cd8957fbc069f796b61217483fd\tdelete tableName=CLIENT_SESSION_AUTH_STATUS; delete tableName=CLIENT_SESSION_ROLE; delete tableName=CLIENT_SESSION_PROT_MAPPER; delete tableName=CLIENT_SESSION_NOTE; delete tableName=CLIENT_SESSION; delete tableName=USER_SESSION_NOTE; delete table...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.6.1_from15\tmposolda@redhat.com\tMETA-INF/jpa-changelog-1.6.1.xml\t2023-10-29 15:39:52.850965\t14\tEXECUTED\t9:6005e15e84714cd83226bf7879f54190\taddColumn tableName=REALM; addColumn tableName=KEYCLOAK_ROLE; addColumn tableName=CLIENT; createTable tableName=OFFLINE_USER_SESSION; createTable tableName=OFFLINE_CLIENT_SESSION; addPrimaryKey constraintName=CONSTRAINT_OFFL_US_SES_PK2, tableName=...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.6.1_from16-pre\tmposolda@redhat.com\tMETA-INF/jpa-changelog-1.6.1.xml\t2023-10-29 15:39:52.8518\t15\tMARK_RAN\t9:bf656f5a2b055d07f314431cae76f06c\tdelete tableName=OFFLINE_CLIENT_SESSION; delete tableName=OFFLINE_USER_SESSION\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.6.1_from16\tmposolda@redhat.com\tMETA-INF/jpa-changelog-1.6.1.xml\t2023-10-29 15:39:52.85342\t16\tMARK_RAN\t9:f8dadc9284440469dcf71e25ca6ab99b\tdropPrimaryKey constraintName=CONSTRAINT_OFFLINE_US_SES_PK, tableName=OFFLINE_USER_SESSION; dropPrimaryKey constraintName=CONSTRAINT_OFFLINE_CL_SES_PK, tableName=OFFLINE_CLIENT_SESSION; addColumn tableName=OFFLINE_USER_SESSION; update tableName=OF...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.6.1\tmposolda@redhat.com\tMETA-INF/jpa-changelog-1.6.1.xml\t2023-10-29 15:39:52.855241\t17\tEXECUTED\t9:d41d8cd98f00b204e9800998ecf8427e\tempty\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.7.0\tbburke@redhat.com\tMETA-INF/jpa-changelog-1.7.0.xml\t2023-10-29 15:39:52.884783\t18\tEXECUTED\t9:3368ff0be4c2855ee2dd9ca813b38d8e\tcreateTable tableName=KEYCLOAK_GROUP; createTable tableName=GROUP_ROLE_MAPPING; createTable tableName=GROUP_ATTRIBUTE; createTable tableName=USER_GROUP_MEMBERSHIP; createTable tableName=REALM_DEFAULT_GROUPS; addColumn tableName=IDENTITY_PROVIDER; ...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.8.0\tmposolda@redhat.com\tMETA-INF/jpa-changelog-1.8.0.xml\t2023-10-29 15:39:52.903058\t19\tEXECUTED\t9:8ac2fb5dd030b24c0570a763ed75ed20\taddColumn tableName=IDENTITY_PROVIDER; createTable tableName=CLIENT_TEMPLATE; createTable tableName=CLIENT_TEMPLATE_ATTRIBUTES; createTable tableName=TEMPLATE_SCOPE_MAPPING; dropNotNullConstraint columnName=CLIENT_ID, tableName=PROTOCOL_MAPPER; ad...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.8.0-2\tkeycloak\tMETA-INF/jpa-changelog-1.8.0.xml\t2023-10-29 15:39:52.905097\t20\tEXECUTED\t9:f91ddca9b19743db60e3057679810e6c\tdropDefaultValue columnName=ALGORITHM, tableName=CREDENTIAL; update tableName=CREDENTIAL\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.8.0\tmposolda@redhat.com\tMETA-INF/db2-jpa-changelog-1.8.0.xml\t2023-10-29 15:39:52.910754\t21\tMARK_RAN\t9:831e82914316dc8a57dc09d755f23c51\taddColumn tableName=IDENTITY_PROVIDER; createTable tableName=CLIENT_TEMPLATE; createTable tableName=CLIENT_TEMPLATE_ATTRIBUTES; createTable tableName=TEMPLATE_SCOPE_MAPPING; dropNotNullConstraint columnName=CLIENT_ID, tableName=PROTOCOL_MAPPER; ad...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.8.0-2\tkeycloak\tMETA-INF/db2-jpa-changelog-1.8.0.xml\t2023-10-29 15:39:52.912636\t22\tMARK_RAN\t9:f91ddca9b19743db60e3057679810e6c\tdropDefaultValue columnName=ALGORITHM, tableName=CREDENTIAL; update tableName=CREDENTIAL\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.9.0\tmposolda@redhat.com\tMETA-INF/jpa-changelog-1.9.0.xml\t2023-10-29 15:39:52.920592\t23\tEXECUTED\t9:bc3d0f9e823a69dc21e23e94c7a94bb1\tupdate tableName=REALM; update tableName=REALM; update tableName=REALM; update tableName=REALM; update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=REALM; update tableName=REALM; customChange; dr...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.9.1\tkeycloak\tMETA-INF/jpa-changelog-1.9.1.xml\t2023-10-29 15:39:52.9225\t24\tEXECUTED\t9:c9999da42f543575ab790e76439a2679\tmodifyDataType columnName=PRIVATE_KEY, tableName=REALM; modifyDataType columnName=PUBLIC_KEY, tableName=REALM; modifyDataType columnName=CERTIFICATE, tableName=REALM\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.9.1\tkeycloak\tMETA-INF/db2-jpa-changelog-1.9.1.xml\t2023-10-29 15:39:52.923097\t25\tMARK_RAN\t9:0d6c65c6f58732d81569e77b10ba301d\tmodifyDataType columnName=PRIVATE_KEY, tableName=REALM; modifyDataType columnName=CERTIFICATE, tableName=REALM\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n1.9.2\tkeycloak\tMETA-INF/jpa-changelog-1.9.2.xml\t2023-10-29 15:39:52.933094\t26\tEXECUTED\t9:fc576660fc016ae53d2d4778d84d86d0\tcreateIndex indexName=IDX_USER_EMAIL, tableName=USER_ENTITY; createIndex indexName=IDX_USER_ROLE_MAPPING, tableName=USER_ROLE_MAPPING; createIndex indexName=IDX_USER_GROUP_MAPPING, tableName=USER_GROUP_MEMBERSHIP; createIndex indexName=IDX_USER_CO...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nauthz-2.0.0\tpsilva@redhat.com\tMETA-INF/jpa-changelog-authz-2.0.0.xml\t2023-10-29 15:39:52.964132\t27\tEXECUTED\t9:43ed6b0da89ff77206289e87eaa9c024\tcreateTable tableName=RESOURCE_SERVER; addPrimaryKey constraintName=CONSTRAINT_FARS, tableName=RESOURCE_SERVER; addUniqueConstraint constraintName=UK_AU8TT6T700S9V50BU18WS5HA6, tableName=RESOURCE_SERVER; createTable tableName=RESOURCE_SERVER_RESOU...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nauthz-2.5.1\tpsilva@redhat.com\tMETA-INF/jpa-changelog-authz-2.5.1.xml\t2023-10-29 15:39:52.966529\t28\tEXECUTED\t9:44bae577f551b3738740281eceb4ea70\tupdate tableName=RESOURCE_SERVER_POLICY\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n2.1.0-KEYCLOAK-5461\tbburke@redhat.com\tMETA-INF/jpa-changelog-2.1.0.xml\t2023-10-29 15:39:52.98893\t29\tEXECUTED\t9:bd88e1f833df0420b01e114533aee5e8\tcreateTable tableName=BROKER_LINK; createTable tableName=FED_USER_ATTRIBUTE; createTable tableName=FED_USER_CONSENT; createTable tableName=FED_USER_CONSENT_ROLE; createTable tableName=FED_USER_CONSENT_PROT_MAPPER; createTable tableName=FED_USER_CR...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n2.2.0\tbburke@redhat.com\tMETA-INF/jpa-changelog-2.2.0.xml\t2023-10-29 15:39:52.994839\t30\tEXECUTED\t9:a7022af5267f019d020edfe316ef4371\taddColumn tableName=ADMIN_EVENT_ENTITY; createTable tableName=CREDENTIAL_ATTRIBUTE; createTable tableName=FED_CREDENTIAL_ATTRIBUTE; modifyDataType columnName=VALUE, tableName=CREDENTIAL; addForeignKeyConstraint baseTableName=FED_CREDENTIAL_ATTRIBU...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n2.3.0\tbburke@redhat.com\tMETA-INF/jpa-changelog-2.3.0.xml\t2023-10-29 15:39:53.001191\t31\tEXECUTED\t9:fc155c394040654d6a79227e56f5e25a\tcreateTable tableName=FEDERATED_USER; addPrimaryKey constraintName=CONSTR_FEDERATED_USER, tableName=FEDERATED_USER; dropDefaultValue columnName=TOTP, tableName=USER_ENTITY; dropColumn columnName=TOTP, tableName=USER_ENTITY; addColumn tableName=IDE...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n2.4.0\tbburke@redhat.com\tMETA-INF/jpa-changelog-2.4.0.xml\t2023-10-29 15:39:53.002862\t32\tEXECUTED\t9:eac4ffb2a14795e5dc7b426063e54d88\tcustomChange\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n2.5.0\tbburke@redhat.com\tMETA-INF/jpa-changelog-2.5.0.xml\t2023-10-29 15:39:53.00477\t33\tEXECUTED\t9:54937c05672568c4c64fc9524c1e9462\tcustomChange; modifyDataType columnName=USER_ID, tableName=OFFLINE_USER_SESSION\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n2.5.0-unicode-oracle\thmlnarik@redhat.com\tMETA-INF/jpa-changelog-2.5.0.xml\t2023-10-29 15:39:53.007015\t34\tMARK_RAN\t9:3a32bace77c84d7678d035a7f5a8084e\tmodifyDataType columnName=DESCRIPTION, tableName=AUTHENTICATION_FLOW; modifyDataType columnName=DESCRIPTION, tableName=CLIENT_TEMPLATE; modifyDataType columnName=DESCRIPTION, tableName=RESOURCE_SERVER_POLICY; modifyDataType columnName=DESCRIPTION,...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n2.5.0-unicode-other-dbs\thmlnarik@redhat.com\tMETA-INF/jpa-changelog-2.5.0.xml\t2023-10-29 15:39:53.017994\t35\tEXECUTED\t9:33d72168746f81f98ae3a1e8e0ca3554\tmodifyDataType columnName=DESCRIPTION, tableName=AUTHENTICATION_FLOW; modifyDataType columnName=DESCRIPTION, tableName=CLIENT_TEMPLATE; modifyDataType columnName=DESCRIPTION, tableName=RESOURCE_SERVER_POLICY; modifyDataType columnName=DESCRIPTION,...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n2.5.0-duplicate-email-support\tslawomir@dabek.name\tMETA-INF/jpa-changelog-2.5.0.xml\t2023-10-29 15:39:53.019915\t36\tEXECUTED\t9:61b6d3d7a4c0e0024b0c839da283da0c\taddColumn tableName=REALM\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n2.5.0-unique-group-names\thmlnarik@redhat.com\tMETA-INF/jpa-changelog-2.5.0.xml\t2023-10-29 15:39:53.022173\t37\tEXECUTED\t9:8dcac7bdf7378e7d823cdfddebf72fda\taddUniqueConstraint constraintName=SIBLING_NAMES, tableName=KEYCLOAK_GROUP\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n2.5.1\tbburke@redhat.com\tMETA-INF/jpa-changelog-2.5.1.xml\t2023-10-29 15:39:53.023401\t38\tEXECUTED\t9:a2b870802540cb3faa72098db5388af3\taddColumn tableName=FED_USER_CONSENT\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n3.0.0\tbburke@redhat.com\tMETA-INF/jpa-changelog-3.0.0.xml\t2023-10-29 15:39:53.024816\t39\tEXECUTED\t9:132a67499ba24bcc54fb5cbdcfe7e4c0\taddColumn tableName=IDENTITY_PROVIDER\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n3.2.0-fix\tkeycloak\tMETA-INF/jpa-changelog-3.2.0.xml\t2023-10-29 15:39:53.025574\t40\tMARK_RAN\t9:938f894c032f5430f2b0fafb1a243462\taddNotNullConstraint columnName=REALM_ID, tableName=CLIENT_INITIAL_ACCESS\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n3.2.0-fix-with-keycloak-5416\tkeycloak\tMETA-INF/jpa-changelog-3.2.0.xml\t2023-10-29 15:39:53.026593\t41\tMARK_RAN\t9:845c332ff1874dc5d35974b0babf3006\tdropIndex indexName=IDX_CLIENT_INIT_ACC_REALM, tableName=CLIENT_INITIAL_ACCESS; addNotNullConstraint columnName=REALM_ID, tableName=CLIENT_INITIAL_ACCESS; createIndex indexName=IDX_CLIENT_INIT_ACC_REALM, tableName=CLIENT_INITIAL_ACCESS\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n3.2.0-fix-offline-sessions\thmlnarik\tMETA-INF/jpa-changelog-3.2.0.xml\t2023-10-29 15:39:53.028483\t42\tEXECUTED\t9:fc86359c079781adc577c5a217e4d04c\tcustomChange\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n3.2.0-fixed\tkeycloak\tMETA-INF/jpa-changelog-3.2.0.xml\t2023-10-29 15:39:53.07247\t43\tEXECUTED\t9:59a64800e3c0d09b825f8a3b444fa8f4\taddColumn tableName=REALM; dropPrimaryKey constraintName=CONSTRAINT_OFFL_CL_SES_PK2, tableName=OFFLINE_CLIENT_SESSION; dropColumn columnName=CLIENT_SESSION_ID, tableName=OFFLINE_CLIENT_SESSION; addPrimaryKey constraintName=CONSTRAINT_OFFL_CL_SES_P...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n3.3.0\tkeycloak\tMETA-INF/jpa-changelog-3.3.0.xml\t2023-10-29 15:39:53.07467\t44\tEXECUTED\t9:d48d6da5c6ccf667807f633fe489ce88\taddColumn tableName=USER_ENTITY\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nauthz-3.4.0.CR1-resource-server-pk-change-part1\tglavoie@gmail.com\tMETA-INF/jpa-changelog-authz-3.4.0.CR1.xml\t2023-10-29 15:39:53.076174\t45\tEXECUTED\t9:dde36f7973e80d71fceee683bc5d2951\taddColumn tableName=RESOURCE_SERVER_POLICY; addColumn tableName=RESOURCE_SERVER_RESOURCE; addColumn tableName=RESOURCE_SERVER_SCOPE\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nauthz-3.4.0.CR1-resource-server-pk-change-part2-KEYCLOAK-6095\thmlnarik@redhat.com\tMETA-INF/jpa-changelog-authz-3.4.0.CR1.xml\t2023-10-29 15:39:53.077929\t46\tEXECUTED\t9:b855e9b0a406b34fa323235a0cf4f640\tcustomChange\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nauthz-3.4.0.CR1-resource-server-pk-change-part3-fixed\tglavoie@gmail.com\tMETA-INF/jpa-changelog-authz-3.4.0.CR1.xml\t2023-10-29 15:39:53.078562\t47\tMARK_RAN\t9:51abbacd7b416c50c4421a8cabf7927e\tdropIndex indexName=IDX_RES_SERV_POL_RES_SERV, tableName=RESOURCE_SERVER_POLICY; dropIndex indexName=IDX_RES_SRV_RES_RES_SRV, tableName=RESOURCE_SERVER_RESOURCE; dropIndex indexName=IDX_RES_SRV_SCOPE_RES_SRV, tableName=RESOURCE_SERVER_SCOPE\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nauthz-3.4.0.CR1-resource-server-pk-change-part3-fixed-nodropindex\tglavoie@gmail.com\tMETA-INF/jpa-changelog-authz-3.4.0.CR1.xml\t2023-10-29 15:39:53.092896\t48\tEXECUTED\t9:bdc99e567b3398bac83263d375aad143\taddNotNullConstraint columnName=RESOURCE_SERVER_CLIENT_ID, tableName=RESOURCE_SERVER_POLICY; addNotNullConstraint columnName=RESOURCE_SERVER_CLIENT_ID, tableName=RESOURCE_SERVER_RESOURCE; addNotNullConstraint columnName=RESOURCE_SERVER_CLIENT_ID, ...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nauthn-3.4.0.CR1-refresh-token-max-reuse\tglavoie@gmail.com\tMETA-INF/jpa-changelog-authz-3.4.0.CR1.xml\t2023-10-29 15:39:53.094776\t49\tEXECUTED\t9:d198654156881c46bfba39abd7769e69\taddColumn tableName=REALM\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n3.4.0\tkeycloak\tMETA-INF/jpa-changelog-3.4.0.xml\t2023-10-29 15:39:53.111646\t50\tEXECUTED\t9:cfdd8736332ccdd72c5256ccb42335db\taddPrimaryKey constraintName=CONSTRAINT_REALM_DEFAULT_ROLES, tableName=REALM_DEFAULT_ROLES; addPrimaryKey constraintName=CONSTRAINT_COMPOSITE_ROLE, tableName=COMPOSITE_ROLE; addPrimaryKey constraintName=CONSTR_REALM_DEFAULT_GROUPS, tableName=REALM...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n3.4.0-KEYCLOAK-5230\thmlnarik@redhat.com\tMETA-INF/jpa-changelog-3.4.0.xml\t2023-10-29 15:39:53.124569\t51\tEXECUTED\t9:7c84de3d9bd84d7f077607c1a4dcb714\tcreateIndex indexName=IDX_FU_ATTRIBUTE, tableName=FED_USER_ATTRIBUTE; createIndex indexName=IDX_FU_CONSENT, tableName=FED_USER_CONSENT; createIndex indexName=IDX_FU_CONSENT_RU, tableName=FED_USER_CONSENT; createIndex indexName=IDX_FU_CREDENTIAL, t...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n3.4.1\tpsilva@redhat.com\tMETA-INF/jpa-changelog-3.4.1.xml\t2023-10-29 15:39:53.125904\t52\tEXECUTED\t9:5a6bb36cbefb6a9d6928452c0852af2d\tmodifyDataType columnName=VALUE, tableName=CLIENT_ATTRIBUTES\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n3.4.2\tkeycloak\tMETA-INF/jpa-changelog-3.4.2.xml\t2023-10-29 15:39:53.126919\t53\tEXECUTED\t9:8f23e334dbc59f82e0a328373ca6ced0\tupdate tableName=REALM\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n3.4.2-KEYCLOAK-5172\tmkanis@redhat.com\tMETA-INF/jpa-changelog-3.4.2.xml\t2023-10-29 15:39:53.127985\t54\tEXECUTED\t9:9156214268f09d970cdf0e1564d866af\tupdate tableName=CLIENT\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n4.0.0-KEYCLOAK-6335\tbburke@redhat.com\tMETA-INF/jpa-changelog-4.0.0.xml\t2023-10-29 15:39:53.130476\t55\tEXECUTED\t9:db806613b1ed154826c02610b7dbdf74\tcreateTable tableName=CLIENT_AUTH_FLOW_BINDINGS; addPrimaryKey constraintName=C_CLI_FLOW_BIND, tableName=CLIENT_AUTH_FLOW_BINDINGS\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n4.0.0-CLEANUP-UNUSED-TABLE\tbburke@redhat.com\tMETA-INF/jpa-changelog-4.0.0.xml\t2023-10-29 15:39:53.132156\t56\tEXECUTED\t9:229a041fb72d5beac76bb94a5fa709de\tdropTable tableName=CLIENT_IDENTITY_PROV_MAPPING\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n4.0.0-KEYCLOAK-6228\tbburke@redhat.com\tMETA-INF/jpa-changelog-4.0.0.xml\t2023-10-29 15:39:53.140285\t57\tEXECUTED\t9:079899dade9c1e683f26b2aa9ca6ff04\tdropUniqueConstraint constraintName=UK_JKUWUVD56ONTGSUHOGM8UEWRT, tableName=USER_CONSENT; dropNotNullConstraint columnName=CLIENT_ID, tableName=USER_CONSENT; addColumn tableName=USER_CONSENT; addUniqueConstraint constraintName=UK_JKUWUVD56ONTGSUHO...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n4.0.0-KEYCLOAK-5579-fixed\tmposolda@redhat.com\tMETA-INF/jpa-changelog-4.0.0.xml\t2023-10-29 15:39:53.176453\t58\tEXECUTED\t9:139b79bcbbfe903bb1c2d2a4dbf001d9\tdropForeignKeyConstraint baseTableName=CLIENT_TEMPLATE_ATTRIBUTES, constraintName=FK_CL_TEMPL_ATTR_TEMPL; renameTable newTableName=CLIENT_SCOPE_ATTRIBUTES, oldTableName=CLIENT_TEMPLATE_ATTRIBUTES; renameColumn newColumnName=SCOPE_ID, oldColumnName...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nauthz-4.0.0.CR1\tpsilva@redhat.com\tMETA-INF/jpa-changelog-authz-4.0.0.CR1.xml\t2023-10-29 15:39:53.188861\t59\tEXECUTED\t9:b55738ad889860c625ba2bf483495a04\tcreateTable tableName=RESOURCE_SERVER_PERM_TICKET; addPrimaryKey constraintName=CONSTRAINT_FAPMT, tableName=RESOURCE_SERVER_PERM_TICKET; addForeignKeyConstraint baseTableName=RESOURCE_SERVER_PERM_TICKET, constraintName=FK_FRSRHO213XCX4WNKOG82SSPMT...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nauthz-4.0.0.Beta3\tpsilva@redhat.com\tMETA-INF/jpa-changelog-authz-4.0.0.Beta3.xml\t2023-10-29 15:39:53.191366\t60\tEXECUTED\t9:e0057eac39aa8fc8e09ac6cfa4ae15fe\taddColumn tableName=RESOURCE_SERVER_POLICY; addColumn tableName=RESOURCE_SERVER_PERM_TICKET; addForeignKeyConstraint baseTableName=RESOURCE_SERVER_PERM_TICKET, constraintName=FK_FRSRPO2128CX4WNKOG82SSRFY, referencedTableName=RESOURCE_SERVER_POLICY\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nauthz-4.2.0.Final\tmhajas@redhat.com\tMETA-INF/jpa-changelog-authz-4.2.0.Final.xml\t2023-10-29 15:39:53.194599\t61\tEXECUTED\t9:42a33806f3a0443fe0e7feeec821326c\tcreateTable tableName=RESOURCE_URIS; addForeignKeyConstraint baseTableName=RESOURCE_URIS, constraintName=FK_RESOURCE_SERVER_URIS, referencedTableName=RESOURCE_SERVER_RESOURCE; customChange; dropColumn columnName=URI, tableName=RESOURCE_SERVER_RESO...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nauthz-4.2.0.Final-KEYCLOAK-9944\thmlnarik@redhat.com\tMETA-INF/jpa-changelog-authz-4.2.0.Final.xml\t2023-10-29 15:39:53.196525\t62\tEXECUTED\t9:9968206fca46eecc1f51db9c024bfe56\taddPrimaryKey constraintName=CONSTRAINT_RESOUR_URIS_PK, tableName=RESOURCE_URIS\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n4.2.0-KEYCLOAK-6313\twadahiro@gmail.com\tMETA-INF/jpa-changelog-4.2.0.xml\t2023-10-29 15:39:53.19761\t63\tEXECUTED\t9:92143a6daea0a3f3b8f598c97ce55c3d\taddColumn tableName=REQUIRED_ACTION_PROVIDER\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n4.3.0-KEYCLOAK-7984\twadahiro@gmail.com\tMETA-INF/jpa-changelog-4.3.0.xml\t2023-10-29 15:39:53.198753\t64\tEXECUTED\t9:82bab26a27195d889fb0429003b18f40\tupdate tableName=REQUIRED_ACTION_PROVIDER\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n4.6.0-KEYCLOAK-7950\tpsilva@redhat.com\tMETA-INF/jpa-changelog-4.6.0.xml\t2023-10-29 15:39:53.199758\t65\tEXECUTED\t9:e590c88ddc0b38b0ae4249bbfcb5abc3\tupdate tableName=RESOURCE_SERVER_RESOURCE\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n4.6.0-KEYCLOAK-8377\tkeycloak\tMETA-INF/jpa-changelog-4.6.0.xml\t2023-10-29 15:39:53.204989\t66\tEXECUTED\t9:5c1f475536118dbdc38d5d7977950cc0\tcreateTable tableName=ROLE_ATTRIBUTE; addPrimaryKey constraintName=CONSTRAINT_ROLE_ATTRIBUTE_PK, tableName=ROLE_ATTRIBUTE; addForeignKeyConstraint baseTableName=ROLE_ATTRIBUTE, constraintName=FK_ROLE_ATTRIBUTE_ID, referencedTableName=KEYCLOAK_ROLE...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n4.6.0-KEYCLOAK-8555\tgideonray@gmail.com\tMETA-INF/jpa-changelog-4.6.0.xml\t2023-10-29 15:39:53.207014\t67\tEXECUTED\t9:e7c9f5f9c4d67ccbbcc215440c718a17\tcreateIndex indexName=IDX_COMPONENT_PROVIDER_TYPE, tableName=COMPONENT\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n4.7.0-KEYCLOAK-1267\tsguilhen@redhat.com\tMETA-INF/jpa-changelog-4.7.0.xml\t2023-10-29 15:39:53.208555\t68\tEXECUTED\t9:88e0bfdda924690d6f4e430c53447dd5\taddColumn tableName=REALM\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n4.7.0-KEYCLOAK-7275\tkeycloak\tMETA-INF/jpa-changelog-4.7.0.xml\t2023-10-29 15:39:53.213726\t69\tEXECUTED\t9:f53177f137e1c46b6a88c59ec1cb5218\trenameColumn newColumnName=CREATED_ON, oldColumnName=LAST_SESSION_REFRESH, tableName=OFFLINE_USER_SESSION; addNotNullConstraint columnName=CREATED_ON, tableName=OFFLINE_USER_SESSION; addColumn tableName=OFFLINE_USER_SESSION; customChange; createIn...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n4.8.0-KEYCLOAK-8835\tsguilhen@redhat.com\tMETA-INF/jpa-changelog-4.8.0.xml\t2023-10-29 15:39:53.216356\t70\tEXECUTED\t9:a74d33da4dc42a37ec27121580d1459f\taddNotNullConstraint columnName=SSO_MAX_LIFESPAN_REMEMBER_ME, tableName=REALM; addNotNullConstraint columnName=SSO_IDLE_TIMEOUT_REMEMBER_ME, tableName=REALM\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nauthz-7.0.0-KEYCLOAK-10443\tpsilva@redhat.com\tMETA-INF/jpa-changelog-authz-7.0.0.xml\t2023-10-29 15:39:53.218088\t71\tEXECUTED\t9:fd4ade7b90c3b67fae0bfcfcb42dfb5f\taddColumn tableName=RESOURCE_SERVER\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n8.0.0-adding-credential-columns\tkeycloak\tMETA-INF/jpa-changelog-8.0.0.xml\t2023-10-29 15:39:53.220142\t72\tEXECUTED\t9:aa072ad090bbba210d8f18781b8cebf4\taddColumn tableName=CREDENTIAL; addColumn tableName=FED_USER_CREDENTIAL\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n8.0.0-updating-credential-data-not-oracle-fixed\tkeycloak\tMETA-INF/jpa-changelog-8.0.0.xml\t2023-10-29 15:39:53.222736\t73\tEXECUTED\t9:1ae6be29bab7c2aa376f6983b932be37\tupdate tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n8.0.0-updating-credential-data-oracle-fixed\tkeycloak\tMETA-INF/jpa-changelog-8.0.0.xml\t2023-10-29 15:39:53.223904\t74\tMARK_RAN\t9:14706f286953fc9a25286dbd8fb30d97\tupdate tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL; update tableName=FED_USER_CREDENTIAL\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n8.0.0-credential-cleanup-fixed\tkeycloak\tMETA-INF/jpa-changelog-8.0.0.xml\t2023-10-29 15:39:53.230279\t75\tEXECUTED\t9:2b9cc12779be32c5b40e2e67711a218b\tdropDefaultValue columnName=COUNTER, tableName=CREDENTIAL; dropDefaultValue columnName=DIGITS, tableName=CREDENTIAL; dropDefaultValue columnName=PERIOD, tableName=CREDENTIAL; dropDefaultValue columnName=ALGORITHM, tableName=CREDENTIAL; dropColumn ...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n8.0.0-resource-tag-support\tkeycloak\tMETA-INF/jpa-changelog-8.0.0.xml\t2023-10-29 15:39:53.232897\t76\tEXECUTED\t9:91fa186ce7a5af127a2d7a91ee083cc5\taddColumn tableName=MIGRATION_MODEL; createIndex indexName=IDX_UPDATE_TIME, tableName=MIGRATION_MODEL\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n9.0.0-always-display-client\tkeycloak\tMETA-INF/jpa-changelog-9.0.0.xml\t2023-10-29 15:39:53.234186\t77\tEXECUTED\t9:6335e5c94e83a2639ccd68dd24e2e5ad\taddColumn tableName=CLIENT\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n9.0.0-drop-constraints-for-column-increase\tkeycloak\tMETA-INF/jpa-changelog-9.0.0.xml\t2023-10-29 15:39:53.234797\t78\tMARK_RAN\t9:6bdb5658951e028bfe16fa0a8228b530\tdropUniqueConstraint constraintName=UK_FRSR6T700S9V50BU18WS5PMT, tableName=RESOURCE_SERVER_PERM_TICKET; dropUniqueConstraint constraintName=UK_FRSR6T700S9V50BU18WS5HA6, tableName=RESOURCE_SERVER_RESOURCE; dropPrimaryKey constraintName=CONSTRAINT_O...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n9.0.0-increase-column-size-federated-fk\tkeycloak\tMETA-INF/jpa-changelog-9.0.0.xml\t2023-10-29 15:39:53.242514\t79\tEXECUTED\t9:d5bc15a64117ccad481ce8792d4c608f\tmodifyDataType columnName=CLIENT_ID, tableName=FED_USER_CONSENT; modifyDataType columnName=CLIENT_REALM_CONSTRAINT, tableName=KEYCLOAK_ROLE; modifyDataType columnName=OWNER, tableName=RESOURCE_SERVER_POLICY; modifyDataType columnName=CLIENT_ID, ta...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n9.0.0-recreate-constraints-after-column-increase\tkeycloak\tMETA-INF/jpa-changelog-9.0.0.xml\t2023-10-29 15:39:53.244265\t80\tMARK_RAN\t9:077cba51999515f4d3e7ad5619ab592c\taddNotNullConstraint columnName=CLIENT_ID, tableName=OFFLINE_CLIENT_SESSION; addNotNullConstraint columnName=OWNER, tableName=RESOURCE_SERVER_PERM_TICKET; addNotNullConstraint columnName=REQUESTER, tableName=RESOURCE_SERVER_PERM_TICKET; addNotNull...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n9.0.1-add-index-to-client.client_id\tkeycloak\tMETA-INF/jpa-changelog-9.0.1.xml\t2023-10-29 15:39:53.247415\t81\tEXECUTED\t9:be969f08a163bf47c6b9e9ead8ac2afb\tcreateIndex indexName=IDX_CLIENT_ID, tableName=CLIENT\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n9.0.1-KEYCLOAK-12579-drop-constraints\tkeycloak\tMETA-INF/jpa-changelog-9.0.1.xml\t2023-10-29 15:39:53.248058\t82\tMARK_RAN\t9:6d3bb4408ba5a72f39bd8a0b301ec6e3\tdropUniqueConstraint constraintName=SIBLING_NAMES, tableName=KEYCLOAK_GROUP\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n9.0.1-KEYCLOAK-12579-add-not-null-constraint\tkeycloak\tMETA-INF/jpa-changelog-9.0.1.xml\t2023-10-29 15:39:53.249646\t83\tEXECUTED\t9:966bda61e46bebf3cc39518fbed52fa7\taddNotNullConstraint columnName=PARENT_GROUP, tableName=KEYCLOAK_GROUP\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n9.0.1-KEYCLOAK-12579-recreate-constraints\tkeycloak\tMETA-INF/jpa-changelog-9.0.1.xml\t2023-10-29 15:39:53.250363\t84\tMARK_RAN\t9:8dcac7bdf7378e7d823cdfddebf72fda\taddUniqueConstraint constraintName=SIBLING_NAMES, tableName=KEYCLOAK_GROUP\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n9.0.1-add-index-to-events\tkeycloak\tMETA-INF/jpa-changelog-9.0.1.xml\t2023-10-29 15:39:53.252426\t85\tEXECUTED\t9:7d93d602352a30c0c317e6a609b56599\tcreateIndex indexName=IDX_EVENT_TIME, tableName=EVENT_ENTITY\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nmap-remove-ri\tkeycloak\tMETA-INF/jpa-changelog-11.0.0.xml\t2023-10-29 15:39:53.254047\t86\tEXECUTED\t9:71c5969e6cdd8d7b6f47cebc86d37627\tdropForeignKeyConstraint baseTableName=REALM, constraintName=FK_TRAF444KK6QRKMS7N56AIWQ5Y; dropForeignKeyConstraint baseTableName=KEYCLOAK_ROLE, constraintName=FK_KJHO5LE2C0RAL09FL8CM9WFW9\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nmap-remove-ri\tkeycloak\tMETA-INF/jpa-changelog-12.0.0.xml\t2023-10-29 15:39:53.25649\t87\tEXECUTED\t9:a9ba7d47f065f041b7da856a81762021\tdropForeignKeyConstraint baseTableName=REALM_DEFAULT_GROUPS, constraintName=FK_DEF_GROUPS_GROUP; dropForeignKeyConstraint baseTableName=REALM_DEFAULT_ROLES, constraintName=FK_H4WPD7W4HSOOLNI3H0SW7BTJE; dropForeignKeyConstraint baseTableName=CLIENT...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n12.1.0-add-realm-localization-table\tkeycloak\tMETA-INF/jpa-changelog-12.0.0.xml\t2023-10-29 15:39:53.259692\t88\tEXECUTED\t9:fffabce2bc01e1a8f5110d5278500065\tcreateTable tableName=REALM_LOCALIZATIONS; addPrimaryKey tableName=REALM_LOCALIZATIONS\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\ndefault-roles\tkeycloak\tMETA-INF/jpa-changelog-13.0.0.xml\t2023-10-29 15:39:53.26181\t89\tEXECUTED\t9:fa8a5b5445e3857f4b010bafb5009957\taddColumn tableName=REALM; customChange\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\ndefault-roles-cleanup\tkeycloak\tMETA-INF/jpa-changelog-13.0.0.xml\t2023-10-29 15:39:53.263843\t90\tEXECUTED\t9:67ac3241df9a8582d591c5ed87125f39\tdropTable tableName=REALM_DEFAULT_ROLES; dropTable tableName=CLIENT_DEFAULT_ROLES\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n13.0.0-KEYCLOAK-16844\tkeycloak\tMETA-INF/jpa-changelog-13.0.0.xml\t2023-10-29 15:39:53.265724\t91\tEXECUTED\t9:ad1194d66c937e3ffc82386c050ba089\tcreateIndex indexName=IDX_OFFLINE_USS_PRELOAD, tableName=OFFLINE_USER_SESSION\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nmap-remove-ri-13.0.0\tkeycloak\tMETA-INF/jpa-changelog-13.0.0.xml\t2023-10-29 15:39:53.269145\t92\tEXECUTED\t9:d9be619d94af5a2f5d07b9f003543b91\tdropForeignKeyConstraint baseTableName=DEFAULT_CLIENT_SCOPE, constraintName=FK_R_DEF_CLI_SCOPE_SCOPE; dropForeignKeyConstraint baseTableName=CLIENT_SCOPE_CLIENT, constraintName=FK_C_CLI_SCOPE_SCOPE; dropForeignKeyConstraint baseTableName=CLIENT_SC...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n13.0.0-KEYCLOAK-17992-drop-constraints\tkeycloak\tMETA-INF/jpa-changelog-13.0.0.xml\t2023-10-29 15:39:53.269776\t93\tMARK_RAN\t9:544d201116a0fcc5a5da0925fbbc3bde\tdropPrimaryKey constraintName=C_CLI_SCOPE_BIND, tableName=CLIENT_SCOPE_CLIENT; dropIndex indexName=IDX_CLSCOPE_CL, tableName=CLIENT_SCOPE_CLIENT; dropIndex indexName=IDX_CL_CLSCOPE, tableName=CLIENT_SCOPE_CLIENT\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n13.0.0-increase-column-size-federated\tkeycloak\tMETA-INF/jpa-changelog-13.0.0.xml\t2023-10-29 15:39:53.272454\t94\tEXECUTED\t9:43c0c1055b6761b4b3e89de76d612ccf\tmodifyDataType columnName=CLIENT_ID, tableName=CLIENT_SCOPE_CLIENT; modifyDataType columnName=SCOPE_ID, tableName=CLIENT_SCOPE_CLIENT\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n13.0.0-KEYCLOAK-17992-recreate-constraints\tkeycloak\tMETA-INF/jpa-changelog-13.0.0.xml\t2023-10-29 15:39:53.273612\t95\tMARK_RAN\t9:8bd711fd0330f4fe980494ca43ab1139\taddNotNullConstraint columnName=CLIENT_ID, tableName=CLIENT_SCOPE_CLIENT; addNotNullConstraint columnName=SCOPE_ID, tableName=CLIENT_SCOPE_CLIENT; addPrimaryKey constraintName=C_CLI_SCOPE_BIND, tableName=CLIENT_SCOPE_CLIENT; createIndex indexName=...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\njson-string-accomodation-fixed\tkeycloak\tMETA-INF/jpa-changelog-13.0.0.xml\t2023-10-29 15:39:53.275918\t96\tEXECUTED\t9:e07d2bc0970c348bb06fb63b1f82ddbf\taddColumn tableName=REALM_ATTRIBUTE; update tableName=REALM_ATTRIBUTE; dropColumn columnName=VALUE, tableName=REALM_ATTRIBUTE; renameColumn newColumnName=VALUE, oldColumnName=VALUE_NEW, tableName=REALM_ATTRIBUTE\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n14.0.0-KEYCLOAK-11019\tkeycloak\tMETA-INF/jpa-changelog-14.0.0.xml\t2023-10-29 15:39:53.279407\t97\tEXECUTED\t9:24fb8611e97f29989bea412aa38d12b7\tcreateIndex indexName=IDX_OFFLINE_CSS_PRELOAD, tableName=OFFLINE_CLIENT_SESSION; createIndex indexName=IDX_OFFLINE_USS_BY_USER, tableName=OFFLINE_USER_SESSION; createIndex indexName=IDX_OFFLINE_USS_BY_USERSESS, tableName=OFFLINE_USER_SESSION\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n14.0.0-KEYCLOAK-18286\tkeycloak\tMETA-INF/jpa-changelog-14.0.0.xml\t2023-10-29 15:39:53.28002\t98\tMARK_RAN\t9:259f89014ce2506ee84740cbf7163aa7\tcreateIndex indexName=IDX_CLIENT_ATT_BY_NAME_VALUE, tableName=CLIENT_ATTRIBUTES\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n14.0.0-KEYCLOAK-18286-revert\tkeycloak\tMETA-INF/jpa-changelog-14.0.0.xml\t2023-10-29 15:39:53.282906\t99\tMARK_RAN\t9:04baaf56c116ed19951cbc2cca584022\tdropIndex indexName=IDX_CLIENT_ATT_BY_NAME_VALUE, tableName=CLIENT_ATTRIBUTES\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n14.0.0-KEYCLOAK-18286-supported-dbs\tkeycloak\tMETA-INF/jpa-changelog-14.0.0.xml\t2023-10-29 15:39:53.285181\t100\tEXECUTED\t9:60ca84a0f8c94ec8c3504a5a3bc88ee8\tcreateIndex indexName=IDX_CLIENT_ATT_BY_NAME_VALUE, tableName=CLIENT_ATTRIBUTES\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n14.0.0-KEYCLOAK-18286-unsupported-dbs\tkeycloak\tMETA-INF/jpa-changelog-14.0.0.xml\t2023-10-29 15:39:53.285833\t101\tMARK_RAN\t9:d3d977031d431db16e2c181ce49d73e9\tcreateIndex indexName=IDX_CLIENT_ATT_BY_NAME_VALUE, tableName=CLIENT_ATTRIBUTES\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nKEYCLOAK-17267-add-index-to-user-attributes\tkeycloak\tMETA-INF/jpa-changelog-14.0.0.xml\t2023-10-29 15:39:53.287786\t102\tEXECUTED\t9:0b305d8d1277f3a89a0a53a659ad274c\tcreateIndex indexName=IDX_USER_ATTRIBUTE_NAME, tableName=USER_ATTRIBUTE\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nKEYCLOAK-18146-add-saml-art-binding-identifier\tkeycloak\tMETA-INF/jpa-changelog-14.0.0.xml\t2023-10-29 15:39:53.289239\t103\tEXECUTED\t9:2c374ad2cdfe20e2905a84c8fac48460\tcustomChange\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n15.0.0-KEYCLOAK-18467\tkeycloak\tMETA-INF/jpa-changelog-15.0.0.xml\t2023-10-29 15:39:53.291222\t104\tEXECUTED\t9:47a760639ac597360a8219f5b768b4de\taddColumn tableName=REALM_LOCALIZATIONS; update tableName=REALM_LOCALIZATIONS; dropColumn columnName=TEXTS, tableName=REALM_LOCALIZATIONS; renameColumn newColumnName=TEXTS, oldColumnName=TEXTS_NEW, tableName=REALM_LOCALIZATIONS; addNotNullConstrai...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n17.0.0-9562\tkeycloak\tMETA-INF/jpa-changelog-17.0.0.xml\t2023-10-29 15:39:53.293186\t105\tEXECUTED\t9:a6272f0576727dd8cad2522335f5d99e\tcreateIndex indexName=IDX_USER_SERVICE_ACCOUNT, tableName=USER_ENTITY\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n18.0.0-10625-IDX_ADMIN_EVENT_TIME\tkeycloak\tMETA-INF/jpa-changelog-18.0.0.xml\t2023-10-29 15:39:53.294775\t106\tEXECUTED\t9:015479dbd691d9cc8669282f4828c41d\tcreateIndex indexName=IDX_ADMIN_EVENT_TIME, tableName=ADMIN_EVENT_ENTITY\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n19.0.0-10135\tkeycloak\tMETA-INF/jpa-changelog-19.0.0.xml\t2023-10-29 15:39:53.296222\t107\tEXECUTED\t9:9518e495fdd22f78ad6425cc30630221\tcustomChange\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n20.0.0-12964-supported-dbs\tkeycloak\tMETA-INF/jpa-changelog-20.0.0.xml\t2023-10-29 15:39:53.298348\t108\tEXECUTED\t9:e5f243877199fd96bcc842f27a1656ac\tcreateIndex indexName=IDX_GROUP_ATT_BY_NAME_VALUE, tableName=GROUP_ATTRIBUTE\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n20.0.0-12964-unsupported-dbs\tkeycloak\tMETA-INF/jpa-changelog-20.0.0.xml\t2023-10-29 15:39:53.298926\t109\tMARK_RAN\t9:1a6fcaa85e20bdeae0a9ce49b41946a5\tcreateIndex indexName=IDX_GROUP_ATT_BY_NAME_VALUE, tableName=GROUP_ATTRIBUTE\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\nclient-attributes-string-accomodation-fixed\tkeycloak\tMETA-INF/jpa-changelog-20.0.0.xml\t2023-10-29 15:39:53.301732\t110\tEXECUTED\t9:3f332e13e90739ed0c35b0b25b7822ca\taddColumn tableName=CLIENT_ATTRIBUTES; update tableName=CLIENT_ATTRIBUTES; dropColumn columnName=VALUE, tableName=CLIENT_ATTRIBUTES; renameColumn newColumnName=VALUE, oldColumnName=VALUE_NEW, tableName=CLIENT_ATTRIBUTES\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n21.0.2-17277\tkeycloak\tMETA-INF/jpa-changelog-21.0.2.xml\t2023-10-29 15:39:53.30311\t111\tEXECUTED\t9:7ee1f7a3fb8f5588f171fb9a6ab623c0\tcustomChange\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n21.1.0-19404\tkeycloak\tMETA-INF/jpa-changelog-21.1.0.xml\t2023-10-29 15:39:53.310511\t112\tEXECUTED\t9:3d7e830b52f33676b9d64f7f2b2ea634\tmodifyDataType columnName=DECISION_STRATEGY, tableName=RESOURCE_SERVER_POLICY; modifyDataType columnName=LOGIC, tableName=RESOURCE_SERVER_POLICY; modifyDataType columnName=POLICY_ENFORCE_MODE, tableName=RESOURCE_SERVER\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n21.1.0-19404-2\tkeycloak\tMETA-INF/jpa-changelog-21.1.0.xml\t2023-10-29 15:39:53.312034\t113\tMARK_RAN\t9:627d032e3ef2c06c0e1f73d2ae25c26c\taddColumn tableName=RESOURCE_SERVER_POLICY; update tableName=RESOURCE_SERVER_POLICY; dropColumn columnName=DECISION_STRATEGY, tableName=RESOURCE_SERVER_POLICY; renameColumn newColumnName=DECISION_STRATEGY, oldColumnName=DECISION_STRATEGY_NEW, tabl...\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n22.0.0-17484-updated\tkeycloak\tMETA-INF/jpa-changelog-22.0.0.xml\t2023-10-29 15:39:53.31431\t114\tEXECUTED\t9:90af0bfd30cafc17b9f4d6eccd92b8b3\tcustomChange\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n22.0.5-24031\tkeycloak\tMETA-INF/jpa-changelog-22.0.0.xml\t2023-10-29 15:39:53.314954\t115\tMARK_RAN\t9:a60d2d7b315ec2d3eba9e2f145f9df28\tcustomChange\t\t\\N\t4.23.2\t\\N\t\\N\t8593992115\n\\.\n\n\n--\n-- Data for Name: databasechangeloglock; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.databasechangeloglock (id, locked, lockgranted, lockedby) FROM stdin;\n1\tf\t\\N\t\\N\n1000\tf\t\\N\t\\N\n1001\tf\t\\N\t\\N\n\\.\n\n\n--\n-- Data for Name: default_client_scope; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.default_client_scope (realm_id, scope_id, default_scope) FROM stdin;\nc4049252-49df-41a9-aef7-48d83ef55b9b\t0a22b80a-3a76-4f77-bd61-57f3cadd2be9\tf\nc4049252-49df-41a9-aef7-48d83ef55b9b\t5cdc5c81-7fb6-4f44-8700-a7c3adff5f88\tt\nc4049252-49df-41a9-aef7-48d83ef55b9b\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\tt\nc4049252-49df-41a9-aef7-48d83ef55b9b\t3d28a2fb-9ad1-4ca7-90c8-c33e99daa777\tt\nc4049252-49df-41a9-aef7-48d83ef55b9b\tf298579b-7724-4565-af9c-6efdd6082860\tf\nc4049252-49df-41a9-aef7-48d83ef55b9b\ta2686296-d3ad-4bf6-89ea-43721a012e03\tf\nc4049252-49df-41a9-aef7-48d83ef55b9b\t96380d5b-e4a3-4735-8e58-2bceadd99129\tt\nc4049252-49df-41a9-aef7-48d83ef55b9b\tdab6c561-22d3-4db8-8fc3-8903df2c82ed\tt\nc4049252-49df-41a9-aef7-48d83ef55b9b\t246a65b1-ff00-4e73-beb6-a25f394e2e47\tf\nc4049252-49df-41a9-aef7-48d83ef55b9b\tb1ddd251-07ac-4998-b90c-dd8d6a349d31\tt\n144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t9cedb86c-28d0-42b5-945f-e0f11a78c5e3\tf\n144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tb43942d2-5391-4b9c-ad0f-d6661bd9937d\tt\n144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\tt\n144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t2b788c09-2a4a-4b8b-9cdd-22160e1456ba\tt\n144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t9b2dad39-8598-4b0b-b6a5-0d1a7773e6d1\tf\n144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t8074c632-a4ac-4680-a066-261a3cf0c641\tf\n144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t2c3494c0-4620-4a38-85e1-b5a21f7d89fa\tt\n144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t1719ac57-9b55-456f-a72d-ef7310fc6e18\tt\n144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t0943b915-e341-4e62-8b3f-2fb952439ba8\tf\n144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t797314fe-1913-40f4-8efb-44123269c71f\tt\n\\.\n\n\n--\n-- Data for Name: event_entity; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.event_entity (id, client_id, details_json, error, ip_address, realm_id, session_id, event_time, type, user_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: fed_user_attribute; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.fed_user_attribute (id, name, user_id, realm_id, storage_provider_id, value) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: fed_user_consent; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.fed_user_consent (id, client_id, user_id, realm_id, storage_provider_id, created_date, last_updated_date, client_storage_provider, external_client_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: fed_user_consent_cl_scope; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.fed_user_consent_cl_scope (user_consent_id, scope_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: fed_user_credential; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.fed_user_credential (id, salt, type, created_date, user_id, realm_id, storage_provider_id, user_label, secret_data, credential_data, priority) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: fed_user_group_membership; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.fed_user_group_membership (group_id, user_id, realm_id, storage_provider_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: fed_user_required_action; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.fed_user_required_action (required_action, user_id, realm_id, storage_provider_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: fed_user_role_mapping; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.fed_user_role_mapping (role_id, user_id, realm_id, storage_provider_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: federated_identity; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.federated_identity (identity_provider, realm_id, federated_user_id, federated_username, token, user_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: federated_user; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.federated_user (id, storage_provider_id, realm_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: group_attribute; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.group_attribute (id, name, value, group_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: group_role_mapping; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.group_role_mapping (role_id, group_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: identity_provider; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.identity_provider (internal_id, enabled, provider_alias, provider_id, store_token, authenticate_by_default, realm_id, add_token_role, trust_email, first_broker_login_flow_id, post_broker_login_flow_id, provider_display_name, link_only) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: identity_provider_config; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.identity_provider_config (identity_provider_id, value, name) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: identity_provider_mapper; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.identity_provider_mapper (id, name, idp_alias, idp_mapper_name, realm_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: idp_mapper_config; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.idp_mapper_config (idp_mapper_id, value, name) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: keycloak_group; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.keycloak_group (id, name, parent_group, realm_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: keycloak_role; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.keycloak_role (id, client_realm_constraint, client_role, description, name, realm_id, client, realm) FROM stdin;\n1102e6ef-567c-495f-9704-609b8439f6ed\tc4049252-49df-41a9-aef7-48d83ef55b9b\tf\t${role_default-roles}\tdefault-roles-master\tc4049252-49df-41a9-aef7-48d83ef55b9b\t\\N\t\\N\n46550935-9d8b-463d-acb7-76e5914619fc\tc4049252-49df-41a9-aef7-48d83ef55b9b\tf\t${role_admin}\tadmin\tc4049252-49df-41a9-aef7-48d83ef55b9b\t\\N\t\\N\nfd39b5c8-cb22-4300-8360-90b1e78b203f\tc4049252-49df-41a9-aef7-48d83ef55b9b\tf\t${role_create-realm}\tcreate-realm\tc4049252-49df-41a9-aef7-48d83ef55b9b\t\\N\t\\N\nf1393577-a95d-4b9c-bdb5-acbb7659ec49\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_create-client}\tcreate-client\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n3a797a27-ced8-4019-b89c-be3f96768fef\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_view-realm}\tview-realm\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\ne07412c0-ef2c-45c2-ac71-9b5bca3a1d0e\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_view-users}\tview-users\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n9000313e-62c1-4d0f-8225-74cb6271bad0\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_view-clients}\tview-clients\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n8dc00b7f-a0e9-4d6e-a0a8-6792164c82d6\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_view-events}\tview-events\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n4a1f2e56-820e-4c65-af39-74f9fded83e9\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_view-identity-providers}\tview-identity-providers\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n805f4dd7-9b86-4677-91b8-827b14eb31ad\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_view-authorization}\tview-authorization\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n3f88eb66-8324-42bd-8b5b-1f51b3a717b1\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_manage-realm}\tmanage-realm\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\nbd4d1300-f534-4e16-9310-559b9109fea7\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_manage-users}\tmanage-users\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\ne692941f-c589-49f7-aa22-e7b8f298a3a8\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_manage-clients}\tmanage-clients\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n95a46663-9356-402e-a991-98f432ff5079\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_manage-events}\tmanage-events\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n6ca48090-3d61-49aa-bc01-e20c94dbb8d0\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_manage-identity-providers}\tmanage-identity-providers\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n0f34eb53-edb3-40a9-b861-11e1eff1935e\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_manage-authorization}\tmanage-authorization\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n1c4d9801-19ee-4987-9ef5-1c50e1aeb05e\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_query-users}\tquery-users\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n6605f11e-6eb5-4cd7-8523-be58dbb503b0\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_query-clients}\tquery-clients\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n0584c7d0-dc16-493c-9b26-347920d2ffb1\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_query-realms}\tquery-realms\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n08d7b65a-b41a-458d-b7c5-6d2757965f3d\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_query-groups}\tquery-groups\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n79c6e296-dba7-4181-9736-42d4fd1f83a1\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\tt\t${role_view-profile}\tview-profile\tc4049252-49df-41a9-aef7-48d83ef55b9b\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\t\\N\naa935992-177c-4b37-baf7-f5f8ae32468b\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\tt\t${role_manage-account}\tmanage-account\tc4049252-49df-41a9-aef7-48d83ef55b9b\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\t\\N\n4fde5f2b-6ca3-4c7a-b5e3-1df000e4ecc2\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\tt\t${role_manage-account-links}\tmanage-account-links\tc4049252-49df-41a9-aef7-48d83ef55b9b\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\t\\N\nb5e33e84-a831-402f-a090-c0659ef03da9\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\tt\t${role_view-applications}\tview-applications\tc4049252-49df-41a9-aef7-48d83ef55b9b\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\t\\N\n25529f2b-472e-4967-bd1d-8ca4621d7c73\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\tt\t${role_view-consent}\tview-consent\tc4049252-49df-41a9-aef7-48d83ef55b9b\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\t\\N\nb528aaa9-1cad-45c0-8e91-79052fcf9222\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\tt\t${role_manage-consent}\tmanage-consent\tc4049252-49df-41a9-aef7-48d83ef55b9b\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\t\\N\n7f13753c-dbe8-42b3-9390-ca8d2428a78b\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\tt\t${role_view-groups}\tview-groups\tc4049252-49df-41a9-aef7-48d83ef55b9b\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\t\\N\n435716f4-df97-4237-97d7-e9da997efdea\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\tt\t${role_delete-account}\tdelete-account\tc4049252-49df-41a9-aef7-48d83ef55b9b\te8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\t\\N\n721fec4d-0ef4-4f85-8552-1cb1d3e1a0ba\td5552f1a-d891-405a-a414-9da39d032cd1\tt\t${role_read-token}\tread-token\tc4049252-49df-41a9-aef7-48d83ef55b9b\td5552f1a-d891-405a-a414-9da39d032cd1\t\\N\nbc3af868-084b-4d1d-af35-c3134c13de2f\t132571df-6d24-4658-b2eb-d230a357820c\tt\t${role_impersonation}\timpersonation\tc4049252-49df-41a9-aef7-48d83ef55b9b\t132571df-6d24-4658-b2eb-d230a357820c\t\\N\n418f571e-3a36-4904-800a-c31d10ab4194\tc4049252-49df-41a9-aef7-48d83ef55b9b\tf\t${role_offline-access}\toffline_access\tc4049252-49df-41a9-aef7-48d83ef55b9b\t\\N\t\\N\ne6b153a5-b02e-41db-97d6-2001abf16783\tc4049252-49df-41a9-aef7-48d83ef55b9b\tf\t${role_uma_authorization}\tuma_authorization\tc4049252-49df-41a9-aef7-48d83ef55b9b\t\\N\t\\N\nd18c7a1e-954a-478c-84cd-ab60ac89c351\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tf\t${role_default-roles}\tdefault-roles-ourboard\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t\\N\t\\N\n247b42fe-6f30-4b0b-b5ac-96447df756b8\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_create-client}\tcreate-client\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\n2193a964-a5d7-4e85-8c35-0f3cb7bc9682\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_view-realm}\tview-realm\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\n4904d33e-7188-420d-a264-93f17fd58095\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_view-users}\tview-users\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\nfd88bb53-f80a-44ad-ae72-2368bf56691c\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_view-clients}\tview-clients\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\n87c8788d-9c09-4687-a548-80a4202e33f4\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_view-events}\tview-events\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\n341e9c39-5731-47dd-9c83-a4f94b099a3d\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_view-identity-providers}\tview-identity-providers\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\n68244192-cd4f-4cbf-8abd-e0ed5f73bf49\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_view-authorization}\tview-authorization\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\n8405b1b3-91da-43b0-a71c-af5b22e3d643\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_manage-realm}\tmanage-realm\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\ne4cd51e3-0628-4992-a027-be78a0b6c5b8\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_manage-users}\tmanage-users\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\n2d632b33-d875-491d-8e12-e6cf68f117f2\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_manage-clients}\tmanage-clients\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\nf1e68c21-ffd9-4154-87ac-a25175432022\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_manage-events}\tmanage-events\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\nc3713905-4f3d-4a0d-a564-233ebaf7c0c8\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_manage-identity-providers}\tmanage-identity-providers\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\n90b14ddf-7076-4f7d-a398-fc284b8fb064\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_manage-authorization}\tmanage-authorization\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\n322e8bdd-eebf-4236-8256-acdf1e06199d\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_query-users}\tquery-users\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\nd5a944ea-ca53-4b24-8a5f-3213c5ffe7ea\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_query-clients}\tquery-clients\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\n3b54e2ac-8edc-4c20-a3e2-52a261118492\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_query-realms}\tquery-realms\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\nfb6f79cb-d573-4829-9d60-b92ea98099ef\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_query-groups}\tquery-groups\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\n7d1a1b49-290a-402d-b6cc-9dabffb38f7c\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_realm-admin}\trealm-admin\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\n5131786f-fd1a-4d8f-b7b3-01cc1a36bf8b\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_create-client}\tcreate-client\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\n763fb3a1-85cf-4c38-9554-305c5be95f87\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_view-realm}\tview-realm\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\n1ef446fb-3edc-44ca-9f2a-96884538ac6e\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_view-users}\tview-users\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\nca78f9aa-b871-43c9-8ca0-6323d0780ad7\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_view-clients}\tview-clients\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\n68c8ff41-7fab-413a-8cdd-c270aad522fa\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_view-events}\tview-events\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\n62aeff5e-fe84-4276-b1f0-a4caa6cf65f0\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_view-identity-providers}\tview-identity-providers\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\n4d066f62-161a-43f6-b2b7-1de1092eeba9\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_view-authorization}\tview-authorization\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\n1ed81f82-b318-4314-9485-7f27248ac479\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_manage-realm}\tmanage-realm\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\nc310e7ff-7942-4ab7-9771-e340dc76741d\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_manage-users}\tmanage-users\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\n7da5daec-6a08-4529-b433-c304b410dd97\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_manage-clients}\tmanage-clients\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\na7341a81-3a44-4e53-b7bb-7125b465b356\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_manage-events}\tmanage-events\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\nbd90e0ee-ad9e-4fd9-a8cf-402fc6f01bf0\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_manage-identity-providers}\tmanage-identity-providers\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\na1a0faf6-74ec-46d5-bf3b-cad0e56d6eec\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_manage-authorization}\tmanage-authorization\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\n62ec5875-b826-47c0-a4eb-2a2b768e36b4\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_query-users}\tquery-users\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\n0430e54d-8bc5-44c9-b0a3-e29e9ff5b425\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_query-clients}\tquery-clients\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\n536dbf51-41d0-4b93-9a18-eb0bb5522467\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_query-realms}\tquery-realms\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\n5c850b9c-f0ba-43f8-bea4-2b677a5d1a23\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_query-groups}\tquery-groups\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\n1549050d-2d90-44d9-ac44-1bca24b71a9f\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\tt\t${role_view-profile}\tview-profile\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\t\\N\n85690fb9-8525-4ce3-a100-ab59e38f6d5e\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\tt\t${role_manage-account}\tmanage-account\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\t\\N\n9fdfe2d2-a446-4708-9962-99e7ba60a838\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\tt\t${role_manage-account-links}\tmanage-account-links\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\t\\N\nb39464d1-cc23-4abc-927e-270919134faa\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\tt\t${role_view-applications}\tview-applications\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\t\\N\n8c2afacb-186e-4df8-ae37-775457dfb6cb\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\tt\t${role_view-consent}\tview-consent\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\t\\N\n3edda74a-512a-4b3d-b00d-86b9dc214a78\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\tt\t${role_manage-consent}\tmanage-consent\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\t\\N\ne7d21ace-7e6b-448d-8ab4-72b9c1effd72\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\tt\t${role_view-groups}\tview-groups\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\t\\N\ne4a82cec-b2eb-40e9-8038-8662eb4c4a39\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\tt\t${role_delete-account}\tdelete-account\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tc49b94f0-f612-4dc8-816e-1fa9f0409a97\t\\N\nbebfd4ef-d674-4d74-b332-026e52494fe5\t0405a18f-edff-4184-970f-71159d064fe0\tt\t${role_impersonation}\timpersonation\tc4049252-49df-41a9-aef7-48d83ef55b9b\t0405a18f-edff-4184-970f-71159d064fe0\t\\N\n9e53154e-c887-4a26-bb7c-c76b06dc0656\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\tt\t${role_impersonation}\timpersonation\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t24d6127f-a63c-4a42-a9db-de9e4ea85d09\t\\N\n95ab002b-c74f-437f-8380-64187c7d0f3e\tc498c5a3-6c1b-415c-8c9e-f0eedccf567d\tt\t${role_read-token}\tread-token\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tc498c5a3-6c1b-415c-8c9e-f0eedccf567d\t\\N\ne5ad2c92-6fec-46ff-ac4c-0c519483a403\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tf\t${role_offline-access}\toffline_access\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t\\N\t\\N\n62dcc982-a8a1-4b37-ac34-c5d82842548f\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tf\t${role_uma_authorization}\tuma_authorization\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t\\N\t\\N\n\\.\n\n\n--\n-- Data for Name: migration_model; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.migration_model (id, version, update_time) FROM stdin;\ng8vn2\t22.0.5\t1698593993\n\\.\n\n\n--\n-- Data for Name: offline_client_session; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.offline_client_session (user_session_id, client_id, offline_flag, \"timestamp\", data, client_storage_provider, external_client_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: offline_user_session; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.offline_user_session (user_session_id, user_id, realm_id, created_on, offline_flag, data, last_session_refresh) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: policy_config; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.policy_config (policy_id, name, value) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: protocol_mapper; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.protocol_mapper (id, name, protocol, protocol_mapper_name, client_id, client_scope_id) FROM stdin;\n1c59ac7a-9799-4ef4-a86d-c0a43c9bc955\taudience resolve\topenid-connect\toidc-audience-resolve-mapper\t1edb53f6-3936-46c8-8329-4c694730bebb\t\\N\n08710b81-b0ea-42c2-b769-4733f7d1f1df\tlocale\topenid-connect\toidc-usermodel-attribute-mapper\t5edf2eb5-b5b0-4498-9670-afad3c6058bd\t\\N\nbff7108c-7eee-4471-8ed4-05a840926335\trole list\tsaml\tsaml-role-list-mapper\t\\N\t5cdc5c81-7fb6-4f44-8700-a7c3adff5f88\n7bd38874-5875-44be-96ef-31bada9ef394\tfull name\topenid-connect\toidc-full-name-mapper\t\\N\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\n58b73b88-95d1-4113-87be-546efb65eea1\tfamily name\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\n8b676bbc-e967-4993-a794-f7f5fd385c27\tgiven name\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\n06f9fb82-8354-468d-a6fc-9b88db1ec5a6\tmiddle name\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\n845d8241-ed12-4272-beb3-55fd5cab54d4\tnickname\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\n29dbf182-66ca-4683-a9d9-7555597dfdff\tusername\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\n96eb49de-a7fb-42fe-aa3b-bf8c340381d4\tprofile\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\ne159b1cf-00fe-4474-849f-5a52e8c10c1e\tpicture\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\nf8e2a296-c197-4958-8874-6de6787b98a0\twebsite\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\n8bbe83ad-be21-4155-a6a9-57aec19da6bd\tgender\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\n51345b28-9f23-4ccf-9333-f2b1bb8374c4\tbirthdate\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\n167f7bfc-edfe-41f8-8c19-4dc799e79a4e\tzoneinfo\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\n8e7a04cf-dfc7-4bed-84d5-96d9ebd56c16\tlocale\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\n770212c9-a953-420f-9461-bce3f053d2ab\tupdated at\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8a0c40eb-d40d-4251-9b2d-76e3237c6607\n764250bd-0126-401f-b930-83c0cadfe709\temail\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t3d28a2fb-9ad1-4ca7-90c8-c33e99daa777\ncb6731c8-c3d3-4eec-bb2e-473097c6e9f6\temail verified\topenid-connect\toidc-usermodel-property-mapper\t\\N\t3d28a2fb-9ad1-4ca7-90c8-c33e99daa777\nce5ed8c8-2e38-401d-bd7b-bbbd52d86385\taddress\topenid-connect\toidc-address-mapper\t\\N\tf298579b-7724-4565-af9c-6efdd6082860\nc76345af-4627-4d98-8136-999ac215db32\tphone number\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\ta2686296-d3ad-4bf6-89ea-43721a012e03\nb5156fbf-75da-47f2-8d0a-178f3460c5c8\tphone number verified\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\ta2686296-d3ad-4bf6-89ea-43721a012e03\n313ab72c-3466-4c42-90f8-9dca394845a2\trealm roles\topenid-connect\toidc-usermodel-realm-role-mapper\t\\N\t96380d5b-e4a3-4735-8e58-2bceadd99129\na6c952d2-96cf-4456-9188-a385903b0b05\tclient roles\topenid-connect\toidc-usermodel-client-role-mapper\t\\N\t96380d5b-e4a3-4735-8e58-2bceadd99129\na38fb78e-0e82-4503-819e-79224141cfd0\taudience resolve\topenid-connect\toidc-audience-resolve-mapper\t\\N\t96380d5b-e4a3-4735-8e58-2bceadd99129\n443fe9a9-71fe-46cd-8b0a-f32723f69e5d\tallowed web origins\topenid-connect\toidc-allowed-origins-mapper\t\\N\tdab6c561-22d3-4db8-8fc3-8903df2c82ed\na6c7bc9a-38e2-4b67-a94b-b0e78251d5e1\tupn\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t246a65b1-ff00-4e73-beb6-a25f394e2e47\n5a114898-1e81-4215-a1ea-5c4ebc10fc95\tgroups\topenid-connect\toidc-usermodel-realm-role-mapper\t\\N\t246a65b1-ff00-4e73-beb6-a25f394e2e47\n1ae7fe72-13c0-482e-b961-8380b9ffba5a\tacr loa level\topenid-connect\toidc-acr-mapper\t\\N\tb1ddd251-07ac-4998-b90c-dd8d6a349d31\need60713-cb56-4fe8-b406-3cd2dab40fc3\taudience resolve\topenid-connect\toidc-audience-resolve-mapper\t88d1b5bf-8de3-4650-ab47-03e482718370\t\\N\nb5a04abd-fe83-4682-b5f6-e333aedc3ac1\trole list\tsaml\tsaml-role-list-mapper\t\\N\tb43942d2-5391-4b9c-ad0f-d6661bd9937d\n16665bd8-1cb4-40a2-ace1-0c46b07d6c6e\tfull name\topenid-connect\toidc-full-name-mapper\t\\N\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\nab115889-b568-4443-ab4e-ed75555adf3f\tfamily name\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\n56ed3048-5468-4f41-bd1d-24fdfc9843c1\tgiven name\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\ne2725c6f-35ff-4284-9903-7f7f38cfdecf\tmiddle name\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\nc07b1c62-ba44-42c9-92a8-7ac43b66baaf\tnickname\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\n1c97b14f-bb6c-4b65-a7cd-e0342c864f7d\tusername\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\n3a17b8e7-9f2f-4d0e-94d3-426fbe938854\tprofile\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\n28b34e31-b007-4962-b008-cfafc8c8f663\tpicture\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\n0148cd57-984f-4057-b195-66c145e36a5b\twebsite\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\n82421685-e14e-4621-9b55-d77847e80da5\tgender\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\nc8187656-aeba-471a-937f-b2b25d536ec4\tbirthdate\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\nf808caf0-464b-4964-8b3f-53af392e1d5c\tzoneinfo\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\nb722eec4-f58a-41f8-a1ac-d60aa533fdb5\tlocale\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\na9501bb1-fb73-4a80-bc60-edad03f10cdd\tupdated at\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t0eaf6d7e-2d08-47d1-8894-52d185c66ff3\nc087b32c-ecba-4650-8608-0cc375928869\temail\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t2b788c09-2a4a-4b8b-9cdd-22160e1456ba\n98851daa-dcca-49d4-926c-ac168299948d\temail verified\topenid-connect\toidc-usermodel-property-mapper\t\\N\t2b788c09-2a4a-4b8b-9cdd-22160e1456ba\n480a9491-09c5-4a59-8022-73dd19498942\taddress\topenid-connect\toidc-address-mapper\t\\N\t9b2dad39-8598-4b0b-b6a5-0d1a7773e6d1\nefe4b46a-348b-40c3-a806-b39e3f49bb02\tphone number\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8074c632-a4ac-4680-a066-261a3cf0c641\nadb5ec71-78da-433b-b6d9-f4e4fc8e7cf4\tphone number verified\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t8074c632-a4ac-4680-a066-261a3cf0c641\naa2937d3-1bc1-42dc-965c-8fdf82ea3d28\trealm roles\topenid-connect\toidc-usermodel-realm-role-mapper\t\\N\t2c3494c0-4620-4a38-85e1-b5a21f7d89fa\n7ec0a85c-3d4b-4f9b-8649-76092b398576\tclient roles\topenid-connect\toidc-usermodel-client-role-mapper\t\\N\t2c3494c0-4620-4a38-85e1-b5a21f7d89fa\n02e5a1a5-8ed5-422d-b79c-aec5f6254775\taudience resolve\topenid-connect\toidc-audience-resolve-mapper\t\\N\t2c3494c0-4620-4a38-85e1-b5a21f7d89fa\nfaba174e-4160-4f25-aa14-d8463f6318d7\tallowed web origins\topenid-connect\toidc-allowed-origins-mapper\t\\N\t1719ac57-9b55-456f-a72d-ef7310fc6e18\n54799b6a-b7be-4374-b66b-b0ee8f0269b9\tupn\topenid-connect\toidc-usermodel-attribute-mapper\t\\N\t0943b915-e341-4e62-8b3f-2fb952439ba8\n2eb46434-45b8-4780-ad18-a84189a33fd4\tgroups\topenid-connect\toidc-usermodel-realm-role-mapper\t\\N\t0943b915-e341-4e62-8b3f-2fb952439ba8\nb940500b-3fff-4356-a445-d228f490643f\tacr loa level\topenid-connect\toidc-acr-mapper\t\\N\t797314fe-1913-40f4-8efb-44123269c71f\nbed17a03-65b4-4453-96ed-9c438098f032\tlocale\topenid-connect\toidc-usermodel-attribute-mapper\t8322058b-b4d7-4556-9d03-b35b959fedfe\t\\N\n\\.\n\n\n--\n-- Data for Name: protocol_mapper_config; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.protocol_mapper_config (protocol_mapper_id, value, name) FROM stdin;\n08710b81-b0ea-42c2-b769-4733f7d1f1df\ttrue\tuserinfo.token.claim\n08710b81-b0ea-42c2-b769-4733f7d1f1df\tlocale\tuser.attribute\n08710b81-b0ea-42c2-b769-4733f7d1f1df\ttrue\tid.token.claim\n08710b81-b0ea-42c2-b769-4733f7d1f1df\ttrue\taccess.token.claim\n08710b81-b0ea-42c2-b769-4733f7d1f1df\tlocale\tclaim.name\n08710b81-b0ea-42c2-b769-4733f7d1f1df\tString\tjsonType.label\nbff7108c-7eee-4471-8ed4-05a840926335\tfalse\tsingle\nbff7108c-7eee-4471-8ed4-05a840926335\tBasic\tattribute.nameformat\nbff7108c-7eee-4471-8ed4-05a840926335\tRole\tattribute.name\n06f9fb82-8354-468d-a6fc-9b88db1ec5a6\ttrue\tuserinfo.token.claim\n06f9fb82-8354-468d-a6fc-9b88db1ec5a6\tmiddleName\tuser.attribute\n06f9fb82-8354-468d-a6fc-9b88db1ec5a6\ttrue\tid.token.claim\n06f9fb82-8354-468d-a6fc-9b88db1ec5a6\ttrue\taccess.token.claim\n06f9fb82-8354-468d-a6fc-9b88db1ec5a6\tmiddle_name\tclaim.name\n06f9fb82-8354-468d-a6fc-9b88db1ec5a6\tString\tjsonType.label\n167f7bfc-edfe-41f8-8c19-4dc799e79a4e\ttrue\tuserinfo.token.claim\n167f7bfc-edfe-41f8-8c19-4dc799e79a4e\tzoneinfo\tuser.attribute\n167f7bfc-edfe-41f8-8c19-4dc799e79a4e\ttrue\tid.token.claim\n167f7bfc-edfe-41f8-8c19-4dc799e79a4e\ttrue\taccess.token.claim\n167f7bfc-edfe-41f8-8c19-4dc799e79a4e\tzoneinfo\tclaim.name\n167f7bfc-edfe-41f8-8c19-4dc799e79a4e\tString\tjsonType.label\n29dbf182-66ca-4683-a9d9-7555597dfdff\ttrue\tuserinfo.token.claim\n29dbf182-66ca-4683-a9d9-7555597dfdff\tusername\tuser.attribute\n29dbf182-66ca-4683-a9d9-7555597dfdff\ttrue\tid.token.claim\n29dbf182-66ca-4683-a9d9-7555597dfdff\ttrue\taccess.token.claim\n29dbf182-66ca-4683-a9d9-7555597dfdff\tpreferred_username\tclaim.name\n29dbf182-66ca-4683-a9d9-7555597dfdff\tString\tjsonType.label\n51345b28-9f23-4ccf-9333-f2b1bb8374c4\ttrue\tuserinfo.token.claim\n51345b28-9f23-4ccf-9333-f2b1bb8374c4\tbirthdate\tuser.attribute\n51345b28-9f23-4ccf-9333-f2b1bb8374c4\ttrue\tid.token.claim\n51345b28-9f23-4ccf-9333-f2b1bb8374c4\ttrue\taccess.token.claim\n51345b28-9f23-4ccf-9333-f2b1bb8374c4\tbirthdate\tclaim.name\n51345b28-9f23-4ccf-9333-f2b1bb8374c4\tString\tjsonType.label\n58b73b88-95d1-4113-87be-546efb65eea1\ttrue\tuserinfo.token.claim\n58b73b88-95d1-4113-87be-546efb65eea1\tlastName\tuser.attribute\n58b73b88-95d1-4113-87be-546efb65eea1\ttrue\tid.token.claim\n58b73b88-95d1-4113-87be-546efb65eea1\ttrue\taccess.token.claim\n58b73b88-95d1-4113-87be-546efb65eea1\tfamily_name\tclaim.name\n58b73b88-95d1-4113-87be-546efb65eea1\tString\tjsonType.label\n770212c9-a953-420f-9461-bce3f053d2ab\ttrue\tuserinfo.token.claim\n770212c9-a953-420f-9461-bce3f053d2ab\tupdatedAt\tuser.attribute\n770212c9-a953-420f-9461-bce3f053d2ab\ttrue\tid.token.claim\n770212c9-a953-420f-9461-bce3f053d2ab\ttrue\taccess.token.claim\n770212c9-a953-420f-9461-bce3f053d2ab\tupdated_at\tclaim.name\n770212c9-a953-420f-9461-bce3f053d2ab\tlong\tjsonType.label\n7bd38874-5875-44be-96ef-31bada9ef394\ttrue\tuserinfo.token.claim\n7bd38874-5875-44be-96ef-31bada9ef394\ttrue\tid.token.claim\n7bd38874-5875-44be-96ef-31bada9ef394\ttrue\taccess.token.claim\n845d8241-ed12-4272-beb3-55fd5cab54d4\ttrue\tuserinfo.token.claim\n845d8241-ed12-4272-beb3-55fd5cab54d4\tnickname\tuser.attribute\n845d8241-ed12-4272-beb3-55fd5cab54d4\ttrue\tid.token.claim\n845d8241-ed12-4272-beb3-55fd5cab54d4\ttrue\taccess.token.claim\n845d8241-ed12-4272-beb3-55fd5cab54d4\tnickname\tclaim.name\n845d8241-ed12-4272-beb3-55fd5cab54d4\tString\tjsonType.label\n8b676bbc-e967-4993-a794-f7f5fd385c27\ttrue\tuserinfo.token.claim\n8b676bbc-e967-4993-a794-f7f5fd385c27\tfirstName\tuser.attribute\n8b676bbc-e967-4993-a794-f7f5fd385c27\ttrue\tid.token.claim\n8b676bbc-e967-4993-a794-f7f5fd385c27\ttrue\taccess.token.claim\n8b676bbc-e967-4993-a794-f7f5fd385c27\tgiven_name\tclaim.name\n8b676bbc-e967-4993-a794-f7f5fd385c27\tString\tjsonType.label\n8bbe83ad-be21-4155-a6a9-57aec19da6bd\ttrue\tuserinfo.token.claim\n8bbe83ad-be21-4155-a6a9-57aec19da6bd\tgender\tuser.attribute\n8bbe83ad-be21-4155-a6a9-57aec19da6bd\ttrue\tid.token.claim\n8bbe83ad-be21-4155-a6a9-57aec19da6bd\ttrue\taccess.token.claim\n8bbe83ad-be21-4155-a6a9-57aec19da6bd\tgender\tclaim.name\n8bbe83ad-be21-4155-a6a9-57aec19da6bd\tString\tjsonType.label\n8e7a04cf-dfc7-4bed-84d5-96d9ebd56c16\ttrue\tuserinfo.token.claim\n8e7a04cf-dfc7-4bed-84d5-96d9ebd56c16\tlocale\tuser.attribute\n8e7a04cf-dfc7-4bed-84d5-96d9ebd56c16\ttrue\tid.token.claim\n8e7a04cf-dfc7-4bed-84d5-96d9ebd56c16\ttrue\taccess.token.claim\n8e7a04cf-dfc7-4bed-84d5-96d9ebd56c16\tlocale\tclaim.name\n8e7a04cf-dfc7-4bed-84d5-96d9ebd56c16\tString\tjsonType.label\n96eb49de-a7fb-42fe-aa3b-bf8c340381d4\ttrue\tuserinfo.token.claim\n96eb49de-a7fb-42fe-aa3b-bf8c340381d4\tprofile\tuser.attribute\n96eb49de-a7fb-42fe-aa3b-bf8c340381d4\ttrue\tid.token.claim\n96eb49de-a7fb-42fe-aa3b-bf8c340381d4\ttrue\taccess.token.claim\n96eb49de-a7fb-42fe-aa3b-bf8c340381d4\tprofile\tclaim.name\n96eb49de-a7fb-42fe-aa3b-bf8c340381d4\tString\tjsonType.label\ne159b1cf-00fe-4474-849f-5a52e8c10c1e\ttrue\tuserinfo.token.claim\ne159b1cf-00fe-4474-849f-5a52e8c10c1e\tpicture\tuser.attribute\ne159b1cf-00fe-4474-849f-5a52e8c10c1e\ttrue\tid.token.claim\ne159b1cf-00fe-4474-849f-5a52e8c10c1e\ttrue\taccess.token.claim\ne159b1cf-00fe-4474-849f-5a52e8c10c1e\tpicture\tclaim.name\ne159b1cf-00fe-4474-849f-5a52e8c10c1e\tString\tjsonType.label\nf8e2a296-c197-4958-8874-6de6787b98a0\ttrue\tuserinfo.token.claim\nf8e2a296-c197-4958-8874-6de6787b98a0\twebsite\tuser.attribute\nf8e2a296-c197-4958-8874-6de6787b98a0\ttrue\tid.token.claim\nf8e2a296-c197-4958-8874-6de6787b98a0\ttrue\taccess.token.claim\nf8e2a296-c197-4958-8874-6de6787b98a0\twebsite\tclaim.name\nf8e2a296-c197-4958-8874-6de6787b98a0\tString\tjsonType.label\n764250bd-0126-401f-b930-83c0cadfe709\ttrue\tuserinfo.token.claim\n764250bd-0126-401f-b930-83c0cadfe709\temail\tuser.attribute\n764250bd-0126-401f-b930-83c0cadfe709\ttrue\tid.token.claim\n764250bd-0126-401f-b930-83c0cadfe709\ttrue\taccess.token.claim\n764250bd-0126-401f-b930-83c0cadfe709\temail\tclaim.name\n764250bd-0126-401f-b930-83c0cadfe709\tString\tjsonType.label\ncb6731c8-c3d3-4eec-bb2e-473097c6e9f6\ttrue\tuserinfo.token.claim\ncb6731c8-c3d3-4eec-bb2e-473097c6e9f6\temailVerified\tuser.attribute\ncb6731c8-c3d3-4eec-bb2e-473097c6e9f6\ttrue\tid.token.claim\ncb6731c8-c3d3-4eec-bb2e-473097c6e9f6\ttrue\taccess.token.claim\ncb6731c8-c3d3-4eec-bb2e-473097c6e9f6\temail_verified\tclaim.name\ncb6731c8-c3d3-4eec-bb2e-473097c6e9f6\tboolean\tjsonType.label\nce5ed8c8-2e38-401d-bd7b-bbbd52d86385\tformatted\tuser.attribute.formatted\nce5ed8c8-2e38-401d-bd7b-bbbd52d86385\tcountry\tuser.attribute.country\nce5ed8c8-2e38-401d-bd7b-bbbd52d86385\tpostal_code\tuser.attribute.postal_code\nce5ed8c8-2e38-401d-bd7b-bbbd52d86385\ttrue\tuserinfo.token.claim\nce5ed8c8-2e38-401d-bd7b-bbbd52d86385\tstreet\tuser.attribute.street\nce5ed8c8-2e38-401d-bd7b-bbbd52d86385\ttrue\tid.token.claim\nce5ed8c8-2e38-401d-bd7b-bbbd52d86385\tregion\tuser.attribute.region\nce5ed8c8-2e38-401d-bd7b-bbbd52d86385\ttrue\taccess.token.claim\nce5ed8c8-2e38-401d-bd7b-bbbd52d86385\tlocality\tuser.attribute.locality\nb5156fbf-75da-47f2-8d0a-178f3460c5c8\ttrue\tuserinfo.token.claim\nb5156fbf-75da-47f2-8d0a-178f3460c5c8\tphoneNumberVerified\tuser.attribute\nb5156fbf-75da-47f2-8d0a-178f3460c5c8\ttrue\tid.token.claim\nb5156fbf-75da-47f2-8d0a-178f3460c5c8\ttrue\taccess.token.claim\nb5156fbf-75da-47f2-8d0a-178f3460c5c8\tphone_number_verified\tclaim.name\nb5156fbf-75da-47f2-8d0a-178f3460c5c8\tboolean\tjsonType.label\nc76345af-4627-4d98-8136-999ac215db32\ttrue\tuserinfo.token.claim\nc76345af-4627-4d98-8136-999ac215db32\tphoneNumber\tuser.attribute\nc76345af-4627-4d98-8136-999ac215db32\ttrue\tid.token.claim\nc76345af-4627-4d98-8136-999ac215db32\ttrue\taccess.token.claim\nc76345af-4627-4d98-8136-999ac215db32\tphone_number\tclaim.name\nc76345af-4627-4d98-8136-999ac215db32\tString\tjsonType.label\n313ab72c-3466-4c42-90f8-9dca394845a2\ttrue\tmultivalued\n313ab72c-3466-4c42-90f8-9dca394845a2\tfoo\tuser.attribute\n313ab72c-3466-4c42-90f8-9dca394845a2\ttrue\taccess.token.claim\n313ab72c-3466-4c42-90f8-9dca394845a2\trealm_access.roles\tclaim.name\n313ab72c-3466-4c42-90f8-9dca394845a2\tString\tjsonType.label\na6c952d2-96cf-4456-9188-a385903b0b05\ttrue\tmultivalued\na6c952d2-96cf-4456-9188-a385903b0b05\tfoo\tuser.attribute\na6c952d2-96cf-4456-9188-a385903b0b05\ttrue\taccess.token.claim\na6c952d2-96cf-4456-9188-a385903b0b05\tresource_access.${client_id}.roles\tclaim.name\na6c952d2-96cf-4456-9188-a385903b0b05\tString\tjsonType.label\n5a114898-1e81-4215-a1ea-5c4ebc10fc95\ttrue\tmultivalued\n5a114898-1e81-4215-a1ea-5c4ebc10fc95\tfoo\tuser.attribute\n5a114898-1e81-4215-a1ea-5c4ebc10fc95\ttrue\tid.token.claim\n5a114898-1e81-4215-a1ea-5c4ebc10fc95\ttrue\taccess.token.claim\n5a114898-1e81-4215-a1ea-5c4ebc10fc95\tgroups\tclaim.name\n5a114898-1e81-4215-a1ea-5c4ebc10fc95\tString\tjsonType.label\na6c7bc9a-38e2-4b67-a94b-b0e78251d5e1\ttrue\tuserinfo.token.claim\na6c7bc9a-38e2-4b67-a94b-b0e78251d5e1\tusername\tuser.attribute\na6c7bc9a-38e2-4b67-a94b-b0e78251d5e1\ttrue\tid.token.claim\na6c7bc9a-38e2-4b67-a94b-b0e78251d5e1\ttrue\taccess.token.claim\na6c7bc9a-38e2-4b67-a94b-b0e78251d5e1\tupn\tclaim.name\na6c7bc9a-38e2-4b67-a94b-b0e78251d5e1\tString\tjsonType.label\n1ae7fe72-13c0-482e-b961-8380b9ffba5a\ttrue\tid.token.claim\n1ae7fe72-13c0-482e-b961-8380b9ffba5a\ttrue\taccess.token.claim\nb5a04abd-fe83-4682-b5f6-e333aedc3ac1\tfalse\tsingle\nb5a04abd-fe83-4682-b5f6-e333aedc3ac1\tBasic\tattribute.nameformat\nb5a04abd-fe83-4682-b5f6-e333aedc3ac1\tRole\tattribute.name\n0148cd57-984f-4057-b195-66c145e36a5b\ttrue\tuserinfo.token.claim\n0148cd57-984f-4057-b195-66c145e36a5b\twebsite\tuser.attribute\n0148cd57-984f-4057-b195-66c145e36a5b\ttrue\tid.token.claim\n0148cd57-984f-4057-b195-66c145e36a5b\ttrue\taccess.token.claim\n0148cd57-984f-4057-b195-66c145e36a5b\twebsite\tclaim.name\n0148cd57-984f-4057-b195-66c145e36a5b\tString\tjsonType.label\n16665bd8-1cb4-40a2-ace1-0c46b07d6c6e\ttrue\tuserinfo.token.claim\n16665bd8-1cb4-40a2-ace1-0c46b07d6c6e\ttrue\tid.token.claim\n16665bd8-1cb4-40a2-ace1-0c46b07d6c6e\ttrue\taccess.token.claim\n1c97b14f-bb6c-4b65-a7cd-e0342c864f7d\ttrue\tuserinfo.token.claim\n1c97b14f-bb6c-4b65-a7cd-e0342c864f7d\tusername\tuser.attribute\n1c97b14f-bb6c-4b65-a7cd-e0342c864f7d\ttrue\tid.token.claim\n1c97b14f-bb6c-4b65-a7cd-e0342c864f7d\ttrue\taccess.token.claim\n1c97b14f-bb6c-4b65-a7cd-e0342c864f7d\tpreferred_username\tclaim.name\n1c97b14f-bb6c-4b65-a7cd-e0342c864f7d\tString\tjsonType.label\n28b34e31-b007-4962-b008-cfafc8c8f663\ttrue\tuserinfo.token.claim\n28b34e31-b007-4962-b008-cfafc8c8f663\tpicture\tuser.attribute\n28b34e31-b007-4962-b008-cfafc8c8f663\ttrue\tid.token.claim\n28b34e31-b007-4962-b008-cfafc8c8f663\ttrue\taccess.token.claim\n28b34e31-b007-4962-b008-cfafc8c8f663\tpicture\tclaim.name\n28b34e31-b007-4962-b008-cfafc8c8f663\tString\tjsonType.label\n3a17b8e7-9f2f-4d0e-94d3-426fbe938854\ttrue\tuserinfo.token.claim\n3a17b8e7-9f2f-4d0e-94d3-426fbe938854\tprofile\tuser.attribute\n3a17b8e7-9f2f-4d0e-94d3-426fbe938854\ttrue\tid.token.claim\n3a17b8e7-9f2f-4d0e-94d3-426fbe938854\ttrue\taccess.token.claim\n3a17b8e7-9f2f-4d0e-94d3-426fbe938854\tprofile\tclaim.name\n3a17b8e7-9f2f-4d0e-94d3-426fbe938854\tString\tjsonType.label\n56ed3048-5468-4f41-bd1d-24fdfc9843c1\ttrue\tuserinfo.token.claim\n56ed3048-5468-4f41-bd1d-24fdfc9843c1\tfirstName\tuser.attribute\n56ed3048-5468-4f41-bd1d-24fdfc9843c1\ttrue\tid.token.claim\n56ed3048-5468-4f41-bd1d-24fdfc9843c1\ttrue\taccess.token.claim\n56ed3048-5468-4f41-bd1d-24fdfc9843c1\tgiven_name\tclaim.name\n56ed3048-5468-4f41-bd1d-24fdfc9843c1\tString\tjsonType.label\n82421685-e14e-4621-9b55-d77847e80da5\ttrue\tuserinfo.token.claim\n82421685-e14e-4621-9b55-d77847e80da5\tgender\tuser.attribute\n82421685-e14e-4621-9b55-d77847e80da5\ttrue\tid.token.claim\n82421685-e14e-4621-9b55-d77847e80da5\ttrue\taccess.token.claim\n82421685-e14e-4621-9b55-d77847e80da5\tgender\tclaim.name\n82421685-e14e-4621-9b55-d77847e80da5\tString\tjsonType.label\na9501bb1-fb73-4a80-bc60-edad03f10cdd\ttrue\tuserinfo.token.claim\na9501bb1-fb73-4a80-bc60-edad03f10cdd\tupdatedAt\tuser.attribute\na9501bb1-fb73-4a80-bc60-edad03f10cdd\ttrue\tid.token.claim\na9501bb1-fb73-4a80-bc60-edad03f10cdd\ttrue\taccess.token.claim\na9501bb1-fb73-4a80-bc60-edad03f10cdd\tupdated_at\tclaim.name\na9501bb1-fb73-4a80-bc60-edad03f10cdd\tlong\tjsonType.label\nab115889-b568-4443-ab4e-ed75555adf3f\ttrue\tuserinfo.token.claim\nab115889-b568-4443-ab4e-ed75555adf3f\tlastName\tuser.attribute\nab115889-b568-4443-ab4e-ed75555adf3f\ttrue\tid.token.claim\nab115889-b568-4443-ab4e-ed75555adf3f\ttrue\taccess.token.claim\nab115889-b568-4443-ab4e-ed75555adf3f\tfamily_name\tclaim.name\nab115889-b568-4443-ab4e-ed75555adf3f\tString\tjsonType.label\nb722eec4-f58a-41f8-a1ac-d60aa533fdb5\ttrue\tuserinfo.token.claim\nb722eec4-f58a-41f8-a1ac-d60aa533fdb5\tlocale\tuser.attribute\nb722eec4-f58a-41f8-a1ac-d60aa533fdb5\ttrue\tid.token.claim\nb722eec4-f58a-41f8-a1ac-d60aa533fdb5\ttrue\taccess.token.claim\nb722eec4-f58a-41f8-a1ac-d60aa533fdb5\tlocale\tclaim.name\nb722eec4-f58a-41f8-a1ac-d60aa533fdb5\tString\tjsonType.label\nc07b1c62-ba44-42c9-92a8-7ac43b66baaf\ttrue\tuserinfo.token.claim\nc07b1c62-ba44-42c9-92a8-7ac43b66baaf\tnickname\tuser.attribute\nc07b1c62-ba44-42c9-92a8-7ac43b66baaf\ttrue\tid.token.claim\nc07b1c62-ba44-42c9-92a8-7ac43b66baaf\ttrue\taccess.token.claim\nc07b1c62-ba44-42c9-92a8-7ac43b66baaf\tnickname\tclaim.name\nc07b1c62-ba44-42c9-92a8-7ac43b66baaf\tString\tjsonType.label\nc8187656-aeba-471a-937f-b2b25d536ec4\ttrue\tuserinfo.token.claim\nc8187656-aeba-471a-937f-b2b25d536ec4\tbirthdate\tuser.attribute\nc8187656-aeba-471a-937f-b2b25d536ec4\ttrue\tid.token.claim\nc8187656-aeba-471a-937f-b2b25d536ec4\ttrue\taccess.token.claim\nc8187656-aeba-471a-937f-b2b25d536ec4\tbirthdate\tclaim.name\nc8187656-aeba-471a-937f-b2b25d536ec4\tString\tjsonType.label\ne2725c6f-35ff-4284-9903-7f7f38cfdecf\ttrue\tuserinfo.token.claim\ne2725c6f-35ff-4284-9903-7f7f38cfdecf\tmiddleName\tuser.attribute\ne2725c6f-35ff-4284-9903-7f7f38cfdecf\ttrue\tid.token.claim\ne2725c6f-35ff-4284-9903-7f7f38cfdecf\ttrue\taccess.token.claim\ne2725c6f-35ff-4284-9903-7f7f38cfdecf\tmiddle_name\tclaim.name\ne2725c6f-35ff-4284-9903-7f7f38cfdecf\tString\tjsonType.label\nf808caf0-464b-4964-8b3f-53af392e1d5c\ttrue\tuserinfo.token.claim\nf808caf0-464b-4964-8b3f-53af392e1d5c\tzoneinfo\tuser.attribute\nf808caf0-464b-4964-8b3f-53af392e1d5c\ttrue\tid.token.claim\nf808caf0-464b-4964-8b3f-53af392e1d5c\ttrue\taccess.token.claim\nf808caf0-464b-4964-8b3f-53af392e1d5c\tzoneinfo\tclaim.name\nf808caf0-464b-4964-8b3f-53af392e1d5c\tString\tjsonType.label\n98851daa-dcca-49d4-926c-ac168299948d\ttrue\tuserinfo.token.claim\n98851daa-dcca-49d4-926c-ac168299948d\temailVerified\tuser.attribute\n98851daa-dcca-49d4-926c-ac168299948d\ttrue\tid.token.claim\n98851daa-dcca-49d4-926c-ac168299948d\ttrue\taccess.token.claim\n98851daa-dcca-49d4-926c-ac168299948d\temail_verified\tclaim.name\n98851daa-dcca-49d4-926c-ac168299948d\tboolean\tjsonType.label\nc087b32c-ecba-4650-8608-0cc375928869\ttrue\tuserinfo.token.claim\nc087b32c-ecba-4650-8608-0cc375928869\temail\tuser.attribute\nc087b32c-ecba-4650-8608-0cc375928869\ttrue\tid.token.claim\nc087b32c-ecba-4650-8608-0cc375928869\ttrue\taccess.token.claim\nc087b32c-ecba-4650-8608-0cc375928869\temail\tclaim.name\nc087b32c-ecba-4650-8608-0cc375928869\tString\tjsonType.label\n480a9491-09c5-4a59-8022-73dd19498942\tformatted\tuser.attribute.formatted\n480a9491-09c5-4a59-8022-73dd19498942\tcountry\tuser.attribute.country\n480a9491-09c5-4a59-8022-73dd19498942\tpostal_code\tuser.attribute.postal_code\n480a9491-09c5-4a59-8022-73dd19498942\ttrue\tuserinfo.token.claim\n480a9491-09c5-4a59-8022-73dd19498942\tstreet\tuser.attribute.street\n480a9491-09c5-4a59-8022-73dd19498942\ttrue\tid.token.claim\n480a9491-09c5-4a59-8022-73dd19498942\tregion\tuser.attribute.region\n480a9491-09c5-4a59-8022-73dd19498942\ttrue\taccess.token.claim\n480a9491-09c5-4a59-8022-73dd19498942\tlocality\tuser.attribute.locality\nadb5ec71-78da-433b-b6d9-f4e4fc8e7cf4\ttrue\tuserinfo.token.claim\nadb5ec71-78da-433b-b6d9-f4e4fc8e7cf4\tphoneNumberVerified\tuser.attribute\nadb5ec71-78da-433b-b6d9-f4e4fc8e7cf4\ttrue\tid.token.claim\nadb5ec71-78da-433b-b6d9-f4e4fc8e7cf4\ttrue\taccess.token.claim\nadb5ec71-78da-433b-b6d9-f4e4fc8e7cf4\tphone_number_verified\tclaim.name\nadb5ec71-78da-433b-b6d9-f4e4fc8e7cf4\tboolean\tjsonType.label\nefe4b46a-348b-40c3-a806-b39e3f49bb02\ttrue\tuserinfo.token.claim\nefe4b46a-348b-40c3-a806-b39e3f49bb02\tphoneNumber\tuser.attribute\nefe4b46a-348b-40c3-a806-b39e3f49bb02\ttrue\tid.token.claim\nefe4b46a-348b-40c3-a806-b39e3f49bb02\ttrue\taccess.token.claim\nefe4b46a-348b-40c3-a806-b39e3f49bb02\tphone_number\tclaim.name\nefe4b46a-348b-40c3-a806-b39e3f49bb02\tString\tjsonType.label\n7ec0a85c-3d4b-4f9b-8649-76092b398576\ttrue\tmultivalued\n7ec0a85c-3d4b-4f9b-8649-76092b398576\tfoo\tuser.attribute\n7ec0a85c-3d4b-4f9b-8649-76092b398576\ttrue\taccess.token.claim\n7ec0a85c-3d4b-4f9b-8649-76092b398576\tresource_access.${client_id}.roles\tclaim.name\n7ec0a85c-3d4b-4f9b-8649-76092b398576\tString\tjsonType.label\naa2937d3-1bc1-42dc-965c-8fdf82ea3d28\ttrue\tmultivalued\naa2937d3-1bc1-42dc-965c-8fdf82ea3d28\tfoo\tuser.attribute\naa2937d3-1bc1-42dc-965c-8fdf82ea3d28\ttrue\taccess.token.claim\naa2937d3-1bc1-42dc-965c-8fdf82ea3d28\trealm_access.roles\tclaim.name\naa2937d3-1bc1-42dc-965c-8fdf82ea3d28\tString\tjsonType.label\n2eb46434-45b8-4780-ad18-a84189a33fd4\ttrue\tmultivalued\n2eb46434-45b8-4780-ad18-a84189a33fd4\tfoo\tuser.attribute\n2eb46434-45b8-4780-ad18-a84189a33fd4\ttrue\tid.token.claim\n2eb46434-45b8-4780-ad18-a84189a33fd4\ttrue\taccess.token.claim\n2eb46434-45b8-4780-ad18-a84189a33fd4\tgroups\tclaim.name\n2eb46434-45b8-4780-ad18-a84189a33fd4\tString\tjsonType.label\n54799b6a-b7be-4374-b66b-b0ee8f0269b9\ttrue\tuserinfo.token.claim\n54799b6a-b7be-4374-b66b-b0ee8f0269b9\tusername\tuser.attribute\n54799b6a-b7be-4374-b66b-b0ee8f0269b9\ttrue\tid.token.claim\n54799b6a-b7be-4374-b66b-b0ee8f0269b9\ttrue\taccess.token.claim\n54799b6a-b7be-4374-b66b-b0ee8f0269b9\tupn\tclaim.name\n54799b6a-b7be-4374-b66b-b0ee8f0269b9\tString\tjsonType.label\nb940500b-3fff-4356-a445-d228f490643f\ttrue\tid.token.claim\nb940500b-3fff-4356-a445-d228f490643f\ttrue\taccess.token.claim\nbed17a03-65b4-4453-96ed-9c438098f032\ttrue\tuserinfo.token.claim\nbed17a03-65b4-4453-96ed-9c438098f032\tlocale\tuser.attribute\nbed17a03-65b4-4453-96ed-9c438098f032\ttrue\tid.token.claim\nbed17a03-65b4-4453-96ed-9c438098f032\ttrue\taccess.token.claim\nbed17a03-65b4-4453-96ed-9c438098f032\tlocale\tclaim.name\nbed17a03-65b4-4453-96ed-9c438098f032\tString\tjsonType.label\n\\.\n\n\n--\n-- Data for Name: realm; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.realm (id, access_code_lifespan, user_action_lifespan, access_token_lifespan, account_theme, admin_theme, email_theme, enabled, events_enabled, events_expiration, login_theme, name, not_before, password_policy, registration_allowed, remember_me, reset_password_allowed, social, ssl_required, sso_idle_timeout, sso_max_lifespan, update_profile_on_soc_login, verify_email, master_admin_client, login_lifespan, internationalization_enabled, default_locale, reg_email_as_username, admin_events_enabled, admin_events_details_enabled, edit_username_allowed, otp_policy_counter, otp_policy_window, otp_policy_period, otp_policy_digits, otp_policy_alg, otp_policy_type, browser_flow, registration_flow, direct_grant_flow, reset_credentials_flow, client_auth_flow, offline_session_idle_timeout, revoke_refresh_token, access_token_life_implicit, login_with_email_allowed, duplicate_emails_allowed, docker_auth_flow, refresh_token_max_reuse, allow_user_managed_access, sso_max_lifespan_remember_me, sso_idle_timeout_remember_me, default_role) FROM stdin;\nc4049252-49df-41a9-aef7-48d83ef55b9b\t60\t300\t60\t\\N\t\\N\t\\N\tt\tf\t0\t\\N\tmaster\t0\t\\N\tf\tf\tf\tf\tEXTERNAL\t1800\t36000\tf\tf\t132571df-6d24-4658-b2eb-d230a357820c\t1800\tf\t\\N\tf\tf\tf\tf\t0\t1\t30\t6\tHmacSHA1\ttotp\t159bae2e-081c-47d5-93fd-7af7e429eaa4\tb471a32c-a016-4141-89d8-3d8d0858a459\t42be49df-6d7d-466d-8de1-61670080ea13\t823e4454-4915-44cd-8e1f-d2fca1cfbc0d\t91b46ef4-7403-4802-8554-63f87060ffe7\t2592000\tf\t900\tt\tf\t8360020f-cfaf-4fcc-8c00-6be8dc2edb68\t0\tf\t0\t0\t1102e6ef-567c-495f-9704-609b8439f6ed\n144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t60\t300\t300\t\\N\t\\N\t\\N\tt\tf\t0\t\\N\tourboard\t0\t\\N\tf\tf\tf\tf\tEXTERNAL\t1800\t36000\tf\tf\t0405a18f-edff-4184-970f-71159d064fe0\t1800\tf\t\\N\tf\tf\tf\tf\t0\t1\t30\t6\tHmacSHA1\ttotp\t471b9257-3796-4a4a-88b5-84d03dc770b6\t994ce356-a898-4e82-9be8-f0b19ba4f3e6\t9117851c-72ce-484f-9056-02c546f2af03\tc9812133-8011-408d-bc85-9857e0964b9f\t906fb94d-9f4d-490b-8c43-f29a304b9bed\t2592000\tf\t900\tt\tf\tff236ccc-385f-4d41-b325-95de0d50bfd5\t0\tf\t0\t0\td18c7a1e-954a-478c-84cd-ab60ac89c351\n\\.\n\n\n--\n-- Data for Name: realm_attribute; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.realm_attribute (name, realm_id, value) FROM stdin;\n_browser_header.contentSecurityPolicyReportOnly\tc4049252-49df-41a9-aef7-48d83ef55b9b\t\n_browser_header.xContentTypeOptions\tc4049252-49df-41a9-aef7-48d83ef55b9b\tnosniff\n_browser_header.referrerPolicy\tc4049252-49df-41a9-aef7-48d83ef55b9b\tno-referrer\n_browser_header.xRobotsTag\tc4049252-49df-41a9-aef7-48d83ef55b9b\tnone\n_browser_header.xFrameOptions\tc4049252-49df-41a9-aef7-48d83ef55b9b\tSAMEORIGIN\n_browser_header.contentSecurityPolicy\tc4049252-49df-41a9-aef7-48d83ef55b9b\tframe-src 'self'; frame-ancestors 'self'; object-src 'none';\n_browser_header.xXSSProtection\tc4049252-49df-41a9-aef7-48d83ef55b9b\t1; mode=block\n_browser_header.strictTransportSecurity\tc4049252-49df-41a9-aef7-48d83ef55b9b\tmax-age=31536000; includeSubDomains\nbruteForceProtected\tc4049252-49df-41a9-aef7-48d83ef55b9b\tfalse\npermanentLockout\tc4049252-49df-41a9-aef7-48d83ef55b9b\tfalse\nmaxFailureWaitSeconds\tc4049252-49df-41a9-aef7-48d83ef55b9b\t900\nminimumQuickLoginWaitSeconds\tc4049252-49df-41a9-aef7-48d83ef55b9b\t60\nwaitIncrementSeconds\tc4049252-49df-41a9-aef7-48d83ef55b9b\t60\nquickLoginCheckMilliSeconds\tc4049252-49df-41a9-aef7-48d83ef55b9b\t1000\nmaxDeltaTimeSeconds\tc4049252-49df-41a9-aef7-48d83ef55b9b\t43200\nfailureFactor\tc4049252-49df-41a9-aef7-48d83ef55b9b\t30\nrealmReusableOtpCode\tc4049252-49df-41a9-aef7-48d83ef55b9b\tfalse\ndisplayName\tc4049252-49df-41a9-aef7-48d83ef55b9b\tKeycloak\ndisplayNameHtml\tc4049252-49df-41a9-aef7-48d83ef55b9b\t<div class=\"kc-logo-text\"><span>Keycloak</span></div>\ndefaultSignatureAlgorithm\tc4049252-49df-41a9-aef7-48d83ef55b9b\tRS256\nofflineSessionMaxLifespanEnabled\tc4049252-49df-41a9-aef7-48d83ef55b9b\tfalse\nofflineSessionMaxLifespan\tc4049252-49df-41a9-aef7-48d83ef55b9b\t5184000\n_browser_header.contentSecurityPolicyReportOnly\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t\n_browser_header.xContentTypeOptions\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tnosniff\n_browser_header.referrerPolicy\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tno-referrer\n_browser_header.xRobotsTag\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tnone\n_browser_header.xFrameOptions\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tSAMEORIGIN\n_browser_header.contentSecurityPolicy\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tframe-src 'self'; frame-ancestors 'self'; object-src 'none';\n_browser_header.xXSSProtection\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t1; mode=block\n_browser_header.strictTransportSecurity\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tmax-age=31536000; includeSubDomains\nbruteForceProtected\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tfalse\npermanentLockout\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tfalse\nmaxFailureWaitSeconds\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t900\nminimumQuickLoginWaitSeconds\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t60\nwaitIncrementSeconds\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t60\nquickLoginCheckMilliSeconds\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t1000\nmaxDeltaTimeSeconds\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t43200\nfailureFactor\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t30\nrealmReusableOtpCode\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tfalse\ndefaultSignatureAlgorithm\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tRS256\nofflineSessionMaxLifespanEnabled\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tfalse\nofflineSessionMaxLifespan\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t5184000\nactionTokenGeneratedByAdminLifespan\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t43200\nactionTokenGeneratedByUserLifespan\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t300\noauth2DeviceCodeLifespan\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t600\noauth2DevicePollingInterval\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t5\nwebAuthnPolicyRpEntityName\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tkeycloak\nwebAuthnPolicySignatureAlgorithms\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tES256\nwebAuthnPolicyRpId\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t\nwebAuthnPolicyAttestationConveyancePreference\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tnot specified\nwebAuthnPolicyAuthenticatorAttachment\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tnot specified\nwebAuthnPolicyRequireResidentKey\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tnot specified\nwebAuthnPolicyUserVerificationRequirement\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tnot specified\nwebAuthnPolicyCreateTimeout\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t0\nwebAuthnPolicyAvoidSameAuthenticatorRegister\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tfalse\nwebAuthnPolicyRpEntityNamePasswordless\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tkeycloak\nwebAuthnPolicySignatureAlgorithmsPasswordless\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tES256\nwebAuthnPolicyRpIdPasswordless\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t\nwebAuthnPolicyAttestationConveyancePreferencePasswordless\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tnot specified\nwebAuthnPolicyAuthenticatorAttachmentPasswordless\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tnot specified\nwebAuthnPolicyRequireResidentKeyPasswordless\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tnot specified\nwebAuthnPolicyUserVerificationRequirementPasswordless\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tnot specified\nwebAuthnPolicyCreateTimeoutPasswordless\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t0\nwebAuthnPolicyAvoidSameAuthenticatorRegisterPasswordless\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tfalse\ncibaBackchannelTokenDeliveryMode\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tpoll\ncibaExpiresIn\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t120\ncibaInterval\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t5\ncibaAuthRequestedUserHint\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tlogin_hint\nparRequestUriLifespan\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\t60\n\\.\n\n\n--\n-- Data for Name: realm_default_groups; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.realm_default_groups (realm_id, group_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: realm_enabled_event_types; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.realm_enabled_event_types (realm_id, value) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: realm_events_listeners; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.realm_events_listeners (realm_id, value) FROM stdin;\nc4049252-49df-41a9-aef7-48d83ef55b9b\tjboss-logging\n144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tjboss-logging\n\\.\n\n\n--\n-- Data for Name: realm_localizations; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.realm_localizations (realm_id, locale, texts) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: realm_required_credential; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.realm_required_credential (type, form_label, input, secret, realm_id) FROM stdin;\npassword\tpassword\tt\tt\tc4049252-49df-41a9-aef7-48d83ef55b9b\npassword\tpassword\tt\tt\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\n\\.\n\n\n--\n-- Data for Name: realm_smtp_config; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.realm_smtp_config (realm_id, value, name) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: realm_supported_locales; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.realm_supported_locales (realm_id, value) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: redirect_uris; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.redirect_uris (client_id, value) FROM stdin;\ne8c97b5e-b4e6-4ea7-b704-fb17a003cd6a\t/realms/master/account/*\n1edb53f6-3936-46c8-8329-4c694730bebb\t/realms/master/account/*\n5edf2eb5-b5b0-4498-9670-afad3c6058bd\t/admin/master/console/*\nc49b94f0-f612-4dc8-816e-1fa9f0409a97\t/realms/ourboard/account/*\n88d1b5bf-8de3-4650-ab47-03e482718370\t/realms/ourboard/account/*\n8322058b-b4d7-4556-9d03-b35b959fedfe\t/admin/ourboard/console/*\ned37d3b3-4644-4240-80c5-81954eb2cb6c\thttp://localhost:1337/google-callback\n\\.\n\n\n--\n-- Data for Name: required_action_config; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.required_action_config (required_action_id, value, name) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: required_action_provider; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.required_action_provider (id, alias, name, realm_id, enabled, default_action, provider_id, priority) FROM stdin;\n492cad44-db4c-4a99-88be-088c1acdcaa7\tVERIFY_EMAIL\tVerify Email\tc4049252-49df-41a9-aef7-48d83ef55b9b\tt\tf\tVERIFY_EMAIL\t50\nff7f6442-ddd9-48a6-a53d-84bfdda8d57d\tUPDATE_PROFILE\tUpdate Profile\tc4049252-49df-41a9-aef7-48d83ef55b9b\tt\tf\tUPDATE_PROFILE\t40\naa4b25b2-59d0-401b-a7f5-7b716640fa36\tCONFIGURE_TOTP\tConfigure OTP\tc4049252-49df-41a9-aef7-48d83ef55b9b\tt\tf\tCONFIGURE_TOTP\t10\naffa9e0b-7df5-4bba-af85-a376d5825083\tUPDATE_PASSWORD\tUpdate Password\tc4049252-49df-41a9-aef7-48d83ef55b9b\tt\tf\tUPDATE_PASSWORD\t30\n36933d80-0413-47a9-8d78-ecf932c84a43\tTERMS_AND_CONDITIONS\tTerms and Conditions\tc4049252-49df-41a9-aef7-48d83ef55b9b\tf\tf\tTERMS_AND_CONDITIONS\t20\ne23a8729-e8f0-4de2-98ad-1bcd2ef297db\tdelete_account\tDelete Account\tc4049252-49df-41a9-aef7-48d83ef55b9b\tf\tf\tdelete_account\t60\n9eb17a07-aebe-4241-960c-3824176425b0\tupdate_user_locale\tUpdate User Locale\tc4049252-49df-41a9-aef7-48d83ef55b9b\tt\tf\tupdate_user_locale\t1000\n01438718-d7a6-48ab-bc08-b94b90bbc0d0\twebauthn-register\tWebauthn Register\tc4049252-49df-41a9-aef7-48d83ef55b9b\tt\tf\twebauthn-register\t70\n8d99e8ab-56a4-43ad-a4df-2b95e1efb5a7\twebauthn-register-passwordless\tWebauthn Register Passwordless\tc4049252-49df-41a9-aef7-48d83ef55b9b\tt\tf\twebauthn-register-passwordless\t80\nb9c7809d-eb43-494b-bab3-6e55729a0d42\tVERIFY_EMAIL\tVerify Email\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tt\tf\tVERIFY_EMAIL\t50\n23014590-2a50-4050-b055-9f223f567383\tUPDATE_PROFILE\tUpdate Profile\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tt\tf\tUPDATE_PROFILE\t40\nc51145a4-2c21-4041-ada8-d43181b86b22\tCONFIGURE_TOTP\tConfigure OTP\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tt\tf\tCONFIGURE_TOTP\t10\n597680fc-1432-41c2-9a1c-0b749ad2da01\tUPDATE_PASSWORD\tUpdate Password\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tt\tf\tUPDATE_PASSWORD\t30\nc7cbb675-99c2-42f4-b4a2-24a79d597446\tTERMS_AND_CONDITIONS\tTerms and Conditions\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tf\tf\tTERMS_AND_CONDITIONS\t20\nae06a9e2-feb1-498b-84a0-60ecc77cc52b\tdelete_account\tDelete Account\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tf\tf\tdelete_account\t60\nb27dd674-6812-41d4-b837-14f870d199bd\tupdate_user_locale\tUpdate User Locale\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tt\tf\tupdate_user_locale\t1000\n53f653ab-f938-4ff4-aa14-8f718fbd87a1\twebauthn-register\tWebauthn Register\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tt\tf\twebauthn-register\t70\n0e0917ce-31c0-463f-9014-21ef27d4aa7e\twebauthn-register-passwordless\tWebauthn Register Passwordless\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tt\tf\twebauthn-register-passwordless\t80\n\\.\n\n\n--\n-- Data for Name: resource_attribute; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.resource_attribute (id, name, value, resource_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: resource_policy; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.resource_policy (resource_id, policy_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: resource_scope; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.resource_scope (resource_id, scope_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: resource_server; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.resource_server (id, allow_rs_remote_mgmt, policy_enforce_mode, decision_strategy) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: resource_server_perm_ticket; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.resource_server_perm_ticket (id, owner, requester, created_timestamp, granted_timestamp, resource_id, scope_id, resource_server_id, policy_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: resource_server_policy; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.resource_server_policy (id, name, description, type, decision_strategy, logic, resource_server_id, owner) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: resource_server_resource; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.resource_server_resource (id, name, type, icon_uri, owner, resource_server_id, owner_managed_access, display_name) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: resource_server_scope; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.resource_server_scope (id, name, icon_uri, resource_server_id, display_name) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: resource_uris; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.resource_uris (resource_id, value) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: role_attribute; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.role_attribute (id, role_id, name, value) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: scope_mapping; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.scope_mapping (client_id, role_id) FROM stdin;\n1edb53f6-3936-46c8-8329-4c694730bebb\taa935992-177c-4b37-baf7-f5f8ae32468b\n1edb53f6-3936-46c8-8329-4c694730bebb\t7f13753c-dbe8-42b3-9390-ca8d2428a78b\n88d1b5bf-8de3-4650-ab47-03e482718370\te7d21ace-7e6b-448d-8ab4-72b9c1effd72\n88d1b5bf-8de3-4650-ab47-03e482718370\t85690fb9-8525-4ce3-a100-ab59e38f6d5e\n\\.\n\n\n--\n-- Data for Name: scope_policy; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.scope_policy (scope_id, policy_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: user_attribute; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.user_attribute (name, value, user_id, id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: user_consent; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.user_consent (id, client_id, user_id, created_date, last_updated_date, client_storage_provider, external_client_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: user_consent_client_scope; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.user_consent_client_scope (user_consent_id, scope_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: user_entity; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.user_entity (id, email, email_constraint, email_verified, enabled, federation_link, first_name, last_name, realm_id, username, created_timestamp, service_account_client_link, not_before) FROM stdin;\n5362dff9-2c51-4a6b-a5fb-e93574fb8f62\t\\N\t0d57669c-6e2f-4bc9-b2f6-be4f1b374663\tf\tt\t\\N\t\\N\t\\N\tc4049252-49df-41a9-aef7-48d83ef55b9b\tadmin\t1698593994657\t\\N\t0\n32796b05-317e-4469-8b52-549ce1d0745f\tourboard-test@example.com\tourboard-test@example.com\tf\tt\t\\N\t\\N\t\\N\t144c0b69-8ccd-41f8-8b1c-ae9ff24bac0a\tourboard-test\t1698594057700\t\\N\t0\n\\.\n\n\n--\n-- Data for Name: user_federation_config; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.user_federation_config (user_federation_provider_id, value, name) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: user_federation_mapper; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.user_federation_mapper (id, name, federation_provider_id, federation_mapper_type, realm_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: user_federation_mapper_config; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.user_federation_mapper_config (user_federation_mapper_id, value, name) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: user_federation_provider; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.user_federation_provider (id, changed_sync_period, display_name, full_sync_period, last_sync, priority, provider_name, realm_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: user_group_membership; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.user_group_membership (group_id, user_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: user_required_action; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.user_required_action (user_id, required_action) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: user_role_mapping; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.user_role_mapping (role_id, user_id) FROM stdin;\n1102e6ef-567c-495f-9704-609b8439f6ed\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\n46550935-9d8b-463d-acb7-76e5914619fc\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\n247b42fe-6f30-4b0b-b5ac-96447df756b8\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\n2193a964-a5d7-4e85-8c35-0f3cb7bc9682\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\n4904d33e-7188-420d-a264-93f17fd58095\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\nfd88bb53-f80a-44ad-ae72-2368bf56691c\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\n87c8788d-9c09-4687-a548-80a4202e33f4\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\n341e9c39-5731-47dd-9c83-a4f94b099a3d\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\n68244192-cd4f-4cbf-8abd-e0ed5f73bf49\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\n8405b1b3-91da-43b0-a71c-af5b22e3d643\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\ne4cd51e3-0628-4992-a027-be78a0b6c5b8\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\n2d632b33-d875-491d-8e12-e6cf68f117f2\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\nf1e68c21-ffd9-4154-87ac-a25175432022\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\nc3713905-4f3d-4a0d-a564-233ebaf7c0c8\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\n90b14ddf-7076-4f7d-a398-fc284b8fb064\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\n322e8bdd-eebf-4236-8256-acdf1e06199d\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\nd5a944ea-ca53-4b24-8a5f-3213c5ffe7ea\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\n3b54e2ac-8edc-4c20-a3e2-52a261118492\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\nfb6f79cb-d573-4829-9d60-b92ea98099ef\t5362dff9-2c51-4a6b-a5fb-e93574fb8f62\nd18c7a1e-954a-478c-84cd-ab60ac89c351\t32796b05-317e-4469-8b52-549ce1d0745f\n\\.\n\n\n--\n-- Data for Name: user_session; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.user_session (id, auth_method, ip_address, last_session_refresh, login_username, realm_id, remember_me, started, user_id, user_session_state, broker_session_id, broker_user_id) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: user_session_note; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.user_session_note (user_session, name, value) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: username_login_failure; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.username_login_failure (realm_id, username, failed_login_not_before, last_failure, last_ip_failure, num_failures) FROM stdin;\n\\.\n\n\n--\n-- Data for Name: web_origins; Type: TABLE DATA; Schema: public; Owner: keycloak\n--\n\nCOPY public.web_origins (client_id, value) FROM stdin;\n5edf2eb5-b5b0-4498-9670-afad3c6058bd\t+\n8322058b-b4d7-4556-9d03-b35b959fedfe\t+\ned37d3b3-4644-4240-80c5-81954eb2cb6c\thttp://localhost:1337\n\\.\n\n\n--\n-- Name: username_login_failure CONSTRAINT_17-2; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.username_login_failure\n    ADD CONSTRAINT \"CONSTRAINT_17-2\" PRIMARY KEY (realm_id, username);\n\n\n--\n-- Name: keycloak_role UK_J3RWUVD56ONTGSUHOGM184WW2-2; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.keycloak_role\n    ADD CONSTRAINT \"UK_J3RWUVD56ONTGSUHOGM184WW2-2\" UNIQUE (name, client_realm_constraint);\n\n\n--\n-- Name: client_auth_flow_bindings c_cli_flow_bind; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_auth_flow_bindings\n    ADD CONSTRAINT c_cli_flow_bind PRIMARY KEY (client_id, binding_name);\n\n\n--\n-- Name: client_scope_client c_cli_scope_bind; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_scope_client\n    ADD CONSTRAINT c_cli_scope_bind PRIMARY KEY (client_id, scope_id);\n\n\n--\n-- Name: client_initial_access cnstr_client_init_acc_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_initial_access\n    ADD CONSTRAINT cnstr_client_init_acc_pk PRIMARY KEY (id);\n\n\n--\n-- Name: realm_default_groups con_group_id_def_groups; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_default_groups\n    ADD CONSTRAINT con_group_id_def_groups UNIQUE (group_id);\n\n\n--\n-- Name: broker_link constr_broker_link_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.broker_link\n    ADD CONSTRAINT constr_broker_link_pk PRIMARY KEY (identity_provider, user_id);\n\n\n--\n-- Name: client_user_session_note constr_cl_usr_ses_note; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_user_session_note\n    ADD CONSTRAINT constr_cl_usr_ses_note PRIMARY KEY (client_session, name);\n\n\n--\n-- Name: component_config constr_component_config_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.component_config\n    ADD CONSTRAINT constr_component_config_pk PRIMARY KEY (id);\n\n\n--\n-- Name: component constr_component_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.component\n    ADD CONSTRAINT constr_component_pk PRIMARY KEY (id);\n\n\n--\n-- Name: fed_user_required_action constr_fed_required_action; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.fed_user_required_action\n    ADD CONSTRAINT constr_fed_required_action PRIMARY KEY (required_action, user_id);\n\n\n--\n-- Name: fed_user_attribute constr_fed_user_attr_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.fed_user_attribute\n    ADD CONSTRAINT constr_fed_user_attr_pk PRIMARY KEY (id);\n\n\n--\n-- Name: fed_user_consent constr_fed_user_consent_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.fed_user_consent\n    ADD CONSTRAINT constr_fed_user_consent_pk PRIMARY KEY (id);\n\n\n--\n-- Name: fed_user_credential constr_fed_user_cred_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.fed_user_credential\n    ADD CONSTRAINT constr_fed_user_cred_pk PRIMARY KEY (id);\n\n\n--\n-- Name: fed_user_group_membership constr_fed_user_group; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.fed_user_group_membership\n    ADD CONSTRAINT constr_fed_user_group PRIMARY KEY (group_id, user_id);\n\n\n--\n-- Name: fed_user_role_mapping constr_fed_user_role; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.fed_user_role_mapping\n    ADD CONSTRAINT constr_fed_user_role PRIMARY KEY (role_id, user_id);\n\n\n--\n-- Name: federated_user constr_federated_user; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.federated_user\n    ADD CONSTRAINT constr_federated_user PRIMARY KEY (id);\n\n\n--\n-- Name: realm_default_groups constr_realm_default_groups; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_default_groups\n    ADD CONSTRAINT constr_realm_default_groups PRIMARY KEY (realm_id, group_id);\n\n\n--\n-- Name: realm_enabled_event_types constr_realm_enabl_event_types; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_enabled_event_types\n    ADD CONSTRAINT constr_realm_enabl_event_types PRIMARY KEY (realm_id, value);\n\n\n--\n-- Name: realm_events_listeners constr_realm_events_listeners; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_events_listeners\n    ADD CONSTRAINT constr_realm_events_listeners PRIMARY KEY (realm_id, value);\n\n\n--\n-- Name: realm_supported_locales constr_realm_supported_locales; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_supported_locales\n    ADD CONSTRAINT constr_realm_supported_locales PRIMARY KEY (realm_id, value);\n\n\n--\n-- Name: identity_provider constraint_2b; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.identity_provider\n    ADD CONSTRAINT constraint_2b PRIMARY KEY (internal_id);\n\n\n--\n-- Name: client_attributes constraint_3c; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_attributes\n    ADD CONSTRAINT constraint_3c PRIMARY KEY (client_id, name);\n\n\n--\n-- Name: event_entity constraint_4; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.event_entity\n    ADD CONSTRAINT constraint_4 PRIMARY KEY (id);\n\n\n--\n-- Name: federated_identity constraint_40; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.federated_identity\n    ADD CONSTRAINT constraint_40 PRIMARY KEY (identity_provider, user_id);\n\n\n--\n-- Name: realm constraint_4a; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm\n    ADD CONSTRAINT constraint_4a PRIMARY KEY (id);\n\n\n--\n-- Name: client_session_role constraint_5; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_session_role\n    ADD CONSTRAINT constraint_5 PRIMARY KEY (client_session, role_id);\n\n\n--\n-- Name: user_session constraint_57; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_session\n    ADD CONSTRAINT constraint_57 PRIMARY KEY (id);\n\n\n--\n-- Name: user_federation_provider constraint_5c; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_federation_provider\n    ADD CONSTRAINT constraint_5c PRIMARY KEY (id);\n\n\n--\n-- Name: client_session_note constraint_5e; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_session_note\n    ADD CONSTRAINT constraint_5e PRIMARY KEY (client_session, name);\n\n\n--\n-- Name: client constraint_7; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client\n    ADD CONSTRAINT constraint_7 PRIMARY KEY (id);\n\n\n--\n-- Name: client_session constraint_8; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_session\n    ADD CONSTRAINT constraint_8 PRIMARY KEY (id);\n\n\n--\n-- Name: scope_mapping constraint_81; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.scope_mapping\n    ADD CONSTRAINT constraint_81 PRIMARY KEY (client_id, role_id);\n\n\n--\n-- Name: client_node_registrations constraint_84; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_node_registrations\n    ADD CONSTRAINT constraint_84 PRIMARY KEY (client_id, name);\n\n\n--\n-- Name: realm_attribute constraint_9; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_attribute\n    ADD CONSTRAINT constraint_9 PRIMARY KEY (name, realm_id);\n\n\n--\n-- Name: realm_required_credential constraint_92; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_required_credential\n    ADD CONSTRAINT constraint_92 PRIMARY KEY (realm_id, type);\n\n\n--\n-- Name: keycloak_role constraint_a; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.keycloak_role\n    ADD CONSTRAINT constraint_a PRIMARY KEY (id);\n\n\n--\n-- Name: admin_event_entity constraint_admin_event_entity; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.admin_event_entity\n    ADD CONSTRAINT constraint_admin_event_entity PRIMARY KEY (id);\n\n\n--\n-- Name: authenticator_config_entry constraint_auth_cfg_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.authenticator_config_entry\n    ADD CONSTRAINT constraint_auth_cfg_pk PRIMARY KEY (authenticator_id, name);\n\n\n--\n-- Name: authentication_execution constraint_auth_exec_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.authentication_execution\n    ADD CONSTRAINT constraint_auth_exec_pk PRIMARY KEY (id);\n\n\n--\n-- Name: authentication_flow constraint_auth_flow_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.authentication_flow\n    ADD CONSTRAINT constraint_auth_flow_pk PRIMARY KEY (id);\n\n\n--\n-- Name: authenticator_config constraint_auth_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.authenticator_config\n    ADD CONSTRAINT constraint_auth_pk PRIMARY KEY (id);\n\n\n--\n-- Name: client_session_auth_status constraint_auth_status_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_session_auth_status\n    ADD CONSTRAINT constraint_auth_status_pk PRIMARY KEY (client_session, authenticator);\n\n\n--\n-- Name: user_role_mapping constraint_c; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_role_mapping\n    ADD CONSTRAINT constraint_c PRIMARY KEY (role_id, user_id);\n\n\n--\n-- Name: composite_role constraint_composite_role; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.composite_role\n    ADD CONSTRAINT constraint_composite_role PRIMARY KEY (composite, child_role);\n\n\n--\n-- Name: client_session_prot_mapper constraint_cs_pmp_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_session_prot_mapper\n    ADD CONSTRAINT constraint_cs_pmp_pk PRIMARY KEY (client_session, protocol_mapper_id);\n\n\n--\n-- Name: identity_provider_config constraint_d; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.identity_provider_config\n    ADD CONSTRAINT constraint_d PRIMARY KEY (identity_provider_id, name);\n\n\n--\n-- Name: policy_config constraint_dpc; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.policy_config\n    ADD CONSTRAINT constraint_dpc PRIMARY KEY (policy_id, name);\n\n\n--\n-- Name: realm_smtp_config constraint_e; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_smtp_config\n    ADD CONSTRAINT constraint_e PRIMARY KEY (realm_id, name);\n\n\n--\n-- Name: credential constraint_f; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.credential\n    ADD CONSTRAINT constraint_f PRIMARY KEY (id);\n\n\n--\n-- Name: user_federation_config constraint_f9; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_federation_config\n    ADD CONSTRAINT constraint_f9 PRIMARY KEY (user_federation_provider_id, name);\n\n\n--\n-- Name: resource_server_perm_ticket constraint_fapmt; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_perm_ticket\n    ADD CONSTRAINT constraint_fapmt PRIMARY KEY (id);\n\n\n--\n-- Name: resource_server_resource constraint_farsr; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_resource\n    ADD CONSTRAINT constraint_farsr PRIMARY KEY (id);\n\n\n--\n-- Name: resource_server_policy constraint_farsrp; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_policy\n    ADD CONSTRAINT constraint_farsrp PRIMARY KEY (id);\n\n\n--\n-- Name: associated_policy constraint_farsrpap; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.associated_policy\n    ADD CONSTRAINT constraint_farsrpap PRIMARY KEY (policy_id, associated_policy_id);\n\n\n--\n-- Name: resource_policy constraint_farsrpp; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_policy\n    ADD CONSTRAINT constraint_farsrpp PRIMARY KEY (resource_id, policy_id);\n\n\n--\n-- Name: resource_server_scope constraint_farsrs; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_scope\n    ADD CONSTRAINT constraint_farsrs PRIMARY KEY (id);\n\n\n--\n-- Name: resource_scope constraint_farsrsp; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_scope\n    ADD CONSTRAINT constraint_farsrsp PRIMARY KEY (resource_id, scope_id);\n\n\n--\n-- Name: scope_policy constraint_farsrsps; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.scope_policy\n    ADD CONSTRAINT constraint_farsrsps PRIMARY KEY (scope_id, policy_id);\n\n\n--\n-- Name: user_entity constraint_fb; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_entity\n    ADD CONSTRAINT constraint_fb PRIMARY KEY (id);\n\n\n--\n-- Name: user_federation_mapper_config constraint_fedmapper_cfg_pm; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_federation_mapper_config\n    ADD CONSTRAINT constraint_fedmapper_cfg_pm PRIMARY KEY (user_federation_mapper_id, name);\n\n\n--\n-- Name: user_federation_mapper constraint_fedmapperpm; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_federation_mapper\n    ADD CONSTRAINT constraint_fedmapperpm PRIMARY KEY (id);\n\n\n--\n-- Name: fed_user_consent_cl_scope constraint_fgrntcsnt_clsc_pm; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.fed_user_consent_cl_scope\n    ADD CONSTRAINT constraint_fgrntcsnt_clsc_pm PRIMARY KEY (user_consent_id, scope_id);\n\n\n--\n-- Name: user_consent_client_scope constraint_grntcsnt_clsc_pm; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_consent_client_scope\n    ADD CONSTRAINT constraint_grntcsnt_clsc_pm PRIMARY KEY (user_consent_id, scope_id);\n\n\n--\n-- Name: user_consent constraint_grntcsnt_pm; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_consent\n    ADD CONSTRAINT constraint_grntcsnt_pm PRIMARY KEY (id);\n\n\n--\n-- Name: keycloak_group constraint_group; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.keycloak_group\n    ADD CONSTRAINT constraint_group PRIMARY KEY (id);\n\n\n--\n-- Name: group_attribute constraint_group_attribute_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.group_attribute\n    ADD CONSTRAINT constraint_group_attribute_pk PRIMARY KEY (id);\n\n\n--\n-- Name: group_role_mapping constraint_group_role; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.group_role_mapping\n    ADD CONSTRAINT constraint_group_role PRIMARY KEY (role_id, group_id);\n\n\n--\n-- Name: identity_provider_mapper constraint_idpm; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.identity_provider_mapper\n    ADD CONSTRAINT constraint_idpm PRIMARY KEY (id);\n\n\n--\n-- Name: idp_mapper_config constraint_idpmconfig; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.idp_mapper_config\n    ADD CONSTRAINT constraint_idpmconfig PRIMARY KEY (idp_mapper_id, name);\n\n\n--\n-- Name: migration_model constraint_migmod; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.migration_model\n    ADD CONSTRAINT constraint_migmod PRIMARY KEY (id);\n\n\n--\n-- Name: offline_client_session constraint_offl_cl_ses_pk3; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.offline_client_session\n    ADD CONSTRAINT constraint_offl_cl_ses_pk3 PRIMARY KEY (user_session_id, client_id, client_storage_provider, external_client_id, offline_flag);\n\n\n--\n-- Name: offline_user_session constraint_offl_us_ses_pk2; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.offline_user_session\n    ADD CONSTRAINT constraint_offl_us_ses_pk2 PRIMARY KEY (user_session_id, offline_flag);\n\n\n--\n-- Name: protocol_mapper constraint_pcm; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.protocol_mapper\n    ADD CONSTRAINT constraint_pcm PRIMARY KEY (id);\n\n\n--\n-- Name: protocol_mapper_config constraint_pmconfig; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.protocol_mapper_config\n    ADD CONSTRAINT constraint_pmconfig PRIMARY KEY (protocol_mapper_id, name);\n\n\n--\n-- Name: redirect_uris constraint_redirect_uris; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.redirect_uris\n    ADD CONSTRAINT constraint_redirect_uris PRIMARY KEY (client_id, value);\n\n\n--\n-- Name: required_action_config constraint_req_act_cfg_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.required_action_config\n    ADD CONSTRAINT constraint_req_act_cfg_pk PRIMARY KEY (required_action_id, name);\n\n\n--\n-- Name: required_action_provider constraint_req_act_prv_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.required_action_provider\n    ADD CONSTRAINT constraint_req_act_prv_pk PRIMARY KEY (id);\n\n\n--\n-- Name: user_required_action constraint_required_action; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_required_action\n    ADD CONSTRAINT constraint_required_action PRIMARY KEY (required_action, user_id);\n\n\n--\n-- Name: resource_uris constraint_resour_uris_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_uris\n    ADD CONSTRAINT constraint_resour_uris_pk PRIMARY KEY (resource_id, value);\n\n\n--\n-- Name: role_attribute constraint_role_attribute_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.role_attribute\n    ADD CONSTRAINT constraint_role_attribute_pk PRIMARY KEY (id);\n\n\n--\n-- Name: user_attribute constraint_user_attribute_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_attribute\n    ADD CONSTRAINT constraint_user_attribute_pk PRIMARY KEY (id);\n\n\n--\n-- Name: user_group_membership constraint_user_group; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_group_membership\n    ADD CONSTRAINT constraint_user_group PRIMARY KEY (group_id, user_id);\n\n\n--\n-- Name: user_session_note constraint_usn_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_session_note\n    ADD CONSTRAINT constraint_usn_pk PRIMARY KEY (user_session, name);\n\n\n--\n-- Name: web_origins constraint_web_origins; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.web_origins\n    ADD CONSTRAINT constraint_web_origins PRIMARY KEY (client_id, value);\n\n\n--\n-- Name: databasechangeloglock databasechangeloglock_pkey; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.databasechangeloglock\n    ADD CONSTRAINT databasechangeloglock_pkey PRIMARY KEY (id);\n\n\n--\n-- Name: client_scope_attributes pk_cl_tmpl_attr; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_scope_attributes\n    ADD CONSTRAINT pk_cl_tmpl_attr PRIMARY KEY (scope_id, name);\n\n\n--\n-- Name: client_scope pk_cli_template; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_scope\n    ADD CONSTRAINT pk_cli_template PRIMARY KEY (id);\n\n\n--\n-- Name: resource_server pk_resource_server; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server\n    ADD CONSTRAINT pk_resource_server PRIMARY KEY (id);\n\n\n--\n-- Name: client_scope_role_mapping pk_template_scope; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_scope_role_mapping\n    ADD CONSTRAINT pk_template_scope PRIMARY KEY (scope_id, role_id);\n\n\n--\n-- Name: default_client_scope r_def_cli_scope_bind; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.default_client_scope\n    ADD CONSTRAINT r_def_cli_scope_bind PRIMARY KEY (realm_id, scope_id);\n\n\n--\n-- Name: realm_localizations realm_localizations_pkey; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_localizations\n    ADD CONSTRAINT realm_localizations_pkey PRIMARY KEY (realm_id, locale);\n\n\n--\n-- Name: resource_attribute res_attr_pk; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_attribute\n    ADD CONSTRAINT res_attr_pk PRIMARY KEY (id);\n\n\n--\n-- Name: keycloak_group sibling_names; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.keycloak_group\n    ADD CONSTRAINT sibling_names UNIQUE (realm_id, parent_group, name);\n\n\n--\n-- Name: identity_provider uk_2daelwnibji49avxsrtuf6xj33; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.identity_provider\n    ADD CONSTRAINT uk_2daelwnibji49avxsrtuf6xj33 UNIQUE (provider_alias, realm_id);\n\n\n--\n-- Name: client uk_b71cjlbenv945rb6gcon438at; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client\n    ADD CONSTRAINT uk_b71cjlbenv945rb6gcon438at UNIQUE (realm_id, client_id);\n\n\n--\n-- Name: client_scope uk_cli_scope; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_scope\n    ADD CONSTRAINT uk_cli_scope UNIQUE (realm_id, name);\n\n\n--\n-- Name: user_entity uk_dykn684sl8up1crfei6eckhd7; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_entity\n    ADD CONSTRAINT uk_dykn684sl8up1crfei6eckhd7 UNIQUE (realm_id, email_constraint);\n\n\n--\n-- Name: resource_server_resource uk_frsr6t700s9v50bu18ws5ha6; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_resource\n    ADD CONSTRAINT uk_frsr6t700s9v50bu18ws5ha6 UNIQUE (name, owner, resource_server_id);\n\n\n--\n-- Name: resource_server_perm_ticket uk_frsr6t700s9v50bu18ws5pmt; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_perm_ticket\n    ADD CONSTRAINT uk_frsr6t700s9v50bu18ws5pmt UNIQUE (owner, requester, resource_server_id, resource_id, scope_id);\n\n\n--\n-- Name: resource_server_policy uk_frsrpt700s9v50bu18ws5ha6; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_policy\n    ADD CONSTRAINT uk_frsrpt700s9v50bu18ws5ha6 UNIQUE (name, resource_server_id);\n\n\n--\n-- Name: resource_server_scope uk_frsrst700s9v50bu18ws5ha6; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_scope\n    ADD CONSTRAINT uk_frsrst700s9v50bu18ws5ha6 UNIQUE (name, resource_server_id);\n\n\n--\n-- Name: user_consent uk_jkuwuvd56ontgsuhogm8uewrt; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_consent\n    ADD CONSTRAINT uk_jkuwuvd56ontgsuhogm8uewrt UNIQUE (client_id, client_storage_provider, external_client_id, user_id);\n\n\n--\n-- Name: realm uk_orvsdmla56612eaefiq6wl5oi; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm\n    ADD CONSTRAINT uk_orvsdmla56612eaefiq6wl5oi UNIQUE (name);\n\n\n--\n-- Name: user_entity uk_ru8tt6t700s9v50bu18ws5ha6; Type: CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_entity\n    ADD CONSTRAINT uk_ru8tt6t700s9v50bu18ws5ha6 UNIQUE (realm_id, username);\n\n\n--\n-- Name: idx_admin_event_time; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_admin_event_time ON public.admin_event_entity USING btree (realm_id, admin_event_time);\n\n\n--\n-- Name: idx_assoc_pol_assoc_pol_id; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_assoc_pol_assoc_pol_id ON public.associated_policy USING btree (associated_policy_id);\n\n\n--\n-- Name: idx_auth_config_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_auth_config_realm ON public.authenticator_config USING btree (realm_id);\n\n\n--\n-- Name: idx_auth_exec_flow; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_auth_exec_flow ON public.authentication_execution USING btree (flow_id);\n\n\n--\n-- Name: idx_auth_exec_realm_flow; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_auth_exec_realm_flow ON public.authentication_execution USING btree (realm_id, flow_id);\n\n\n--\n-- Name: idx_auth_flow_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_auth_flow_realm ON public.authentication_flow USING btree (realm_id);\n\n\n--\n-- Name: idx_cl_clscope; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_cl_clscope ON public.client_scope_client USING btree (scope_id);\n\n\n--\n-- Name: idx_client_id; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_client_id ON public.client USING btree (client_id);\n\n\n--\n-- Name: idx_client_init_acc_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_client_init_acc_realm ON public.client_initial_access USING btree (realm_id);\n\n\n--\n-- Name: idx_client_session_session; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_client_session_session ON public.client_session USING btree (session_id);\n\n\n--\n-- Name: idx_clscope_attrs; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_clscope_attrs ON public.client_scope_attributes USING btree (scope_id);\n\n\n--\n-- Name: idx_clscope_cl; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_clscope_cl ON public.client_scope_client USING btree (client_id);\n\n\n--\n-- Name: idx_clscope_protmap; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_clscope_protmap ON public.protocol_mapper USING btree (client_scope_id);\n\n\n--\n-- Name: idx_clscope_role; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_clscope_role ON public.client_scope_role_mapping USING btree (scope_id);\n\n\n--\n-- Name: idx_compo_config_compo; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_compo_config_compo ON public.component_config USING btree (component_id);\n\n\n--\n-- Name: idx_component_provider_type; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_component_provider_type ON public.component USING btree (provider_type);\n\n\n--\n-- Name: idx_component_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_component_realm ON public.component USING btree (realm_id);\n\n\n--\n-- Name: idx_composite; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_composite ON public.composite_role USING btree (composite);\n\n\n--\n-- Name: idx_composite_child; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_composite_child ON public.composite_role USING btree (child_role);\n\n\n--\n-- Name: idx_defcls_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_defcls_realm ON public.default_client_scope USING btree (realm_id);\n\n\n--\n-- Name: idx_defcls_scope; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_defcls_scope ON public.default_client_scope USING btree (scope_id);\n\n\n--\n-- Name: idx_event_time; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_event_time ON public.event_entity USING btree (realm_id, event_time);\n\n\n--\n-- Name: idx_fedidentity_feduser; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_fedidentity_feduser ON public.federated_identity USING btree (federated_user_id);\n\n\n--\n-- Name: idx_fedidentity_user; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_fedidentity_user ON public.federated_identity USING btree (user_id);\n\n\n--\n-- Name: idx_fu_attribute; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_fu_attribute ON public.fed_user_attribute USING btree (user_id, realm_id, name);\n\n\n--\n-- Name: idx_fu_cnsnt_ext; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_fu_cnsnt_ext ON public.fed_user_consent USING btree (user_id, client_storage_provider, external_client_id);\n\n\n--\n-- Name: idx_fu_consent; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_fu_consent ON public.fed_user_consent USING btree (user_id, client_id);\n\n\n--\n-- Name: idx_fu_consent_ru; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_fu_consent_ru ON public.fed_user_consent USING btree (realm_id, user_id);\n\n\n--\n-- Name: idx_fu_credential; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_fu_credential ON public.fed_user_credential USING btree (user_id, type);\n\n\n--\n-- Name: idx_fu_credential_ru; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_fu_credential_ru ON public.fed_user_credential USING btree (realm_id, user_id);\n\n\n--\n-- Name: idx_fu_group_membership; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_fu_group_membership ON public.fed_user_group_membership USING btree (user_id, group_id);\n\n\n--\n-- Name: idx_fu_group_membership_ru; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_fu_group_membership_ru ON public.fed_user_group_membership USING btree (realm_id, user_id);\n\n\n--\n-- Name: idx_fu_required_action; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_fu_required_action ON public.fed_user_required_action USING btree (user_id, required_action);\n\n\n--\n-- Name: idx_fu_required_action_ru; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_fu_required_action_ru ON public.fed_user_required_action USING btree (realm_id, user_id);\n\n\n--\n-- Name: idx_fu_role_mapping; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_fu_role_mapping ON public.fed_user_role_mapping USING btree (user_id, role_id);\n\n\n--\n-- Name: idx_fu_role_mapping_ru; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_fu_role_mapping_ru ON public.fed_user_role_mapping USING btree (realm_id, user_id);\n\n\n--\n-- Name: idx_group_att_by_name_value; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_group_att_by_name_value ON public.group_attribute USING btree (name, ((value)::character varying(250)));\n\n\n--\n-- Name: idx_group_attr_group; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_group_attr_group ON public.group_attribute USING btree (group_id);\n\n\n--\n-- Name: idx_group_role_mapp_group; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_group_role_mapp_group ON public.group_role_mapping USING btree (group_id);\n\n\n--\n-- Name: idx_id_prov_mapp_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_id_prov_mapp_realm ON public.identity_provider_mapper USING btree (realm_id);\n\n\n--\n-- Name: idx_ident_prov_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_ident_prov_realm ON public.identity_provider USING btree (realm_id);\n\n\n--\n-- Name: idx_keycloak_role_client; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_keycloak_role_client ON public.keycloak_role USING btree (client);\n\n\n--\n-- Name: idx_keycloak_role_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_keycloak_role_realm ON public.keycloak_role USING btree (realm);\n\n\n--\n-- Name: idx_offline_css_preload; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_offline_css_preload ON public.offline_client_session USING btree (client_id, offline_flag);\n\n\n--\n-- Name: idx_offline_uss_by_user; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_offline_uss_by_user ON public.offline_user_session USING btree (user_id, realm_id, offline_flag);\n\n\n--\n-- Name: idx_offline_uss_by_usersess; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_offline_uss_by_usersess ON public.offline_user_session USING btree (realm_id, offline_flag, user_session_id);\n\n\n--\n-- Name: idx_offline_uss_createdon; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_offline_uss_createdon ON public.offline_user_session USING btree (created_on);\n\n\n--\n-- Name: idx_offline_uss_preload; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_offline_uss_preload ON public.offline_user_session USING btree (offline_flag, created_on, user_session_id);\n\n\n--\n-- Name: idx_protocol_mapper_client; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_protocol_mapper_client ON public.protocol_mapper USING btree (client_id);\n\n\n--\n-- Name: idx_realm_attr_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_realm_attr_realm ON public.realm_attribute USING btree (realm_id);\n\n\n--\n-- Name: idx_realm_clscope; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_realm_clscope ON public.client_scope USING btree (realm_id);\n\n\n--\n-- Name: idx_realm_def_grp_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_realm_def_grp_realm ON public.realm_default_groups USING btree (realm_id);\n\n\n--\n-- Name: idx_realm_evt_list_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_realm_evt_list_realm ON public.realm_events_listeners USING btree (realm_id);\n\n\n--\n-- Name: idx_realm_evt_types_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_realm_evt_types_realm ON public.realm_enabled_event_types USING btree (realm_id);\n\n\n--\n-- Name: idx_realm_master_adm_cli; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_realm_master_adm_cli ON public.realm USING btree (master_admin_client);\n\n\n--\n-- Name: idx_realm_supp_local_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_realm_supp_local_realm ON public.realm_supported_locales USING btree (realm_id);\n\n\n--\n-- Name: idx_redir_uri_client; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_redir_uri_client ON public.redirect_uris USING btree (client_id);\n\n\n--\n-- Name: idx_req_act_prov_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_req_act_prov_realm ON public.required_action_provider USING btree (realm_id);\n\n\n--\n-- Name: idx_res_policy_policy; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_res_policy_policy ON public.resource_policy USING btree (policy_id);\n\n\n--\n-- Name: idx_res_scope_scope; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_res_scope_scope ON public.resource_scope USING btree (scope_id);\n\n\n--\n-- Name: idx_res_serv_pol_res_serv; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_res_serv_pol_res_serv ON public.resource_server_policy USING btree (resource_server_id);\n\n\n--\n-- Name: idx_res_srv_res_res_srv; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_res_srv_res_res_srv ON public.resource_server_resource USING btree (resource_server_id);\n\n\n--\n-- Name: idx_res_srv_scope_res_srv; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_res_srv_scope_res_srv ON public.resource_server_scope USING btree (resource_server_id);\n\n\n--\n-- Name: idx_role_attribute; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_role_attribute ON public.role_attribute USING btree (role_id);\n\n\n--\n-- Name: idx_role_clscope; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_role_clscope ON public.client_scope_role_mapping USING btree (role_id);\n\n\n--\n-- Name: idx_scope_mapping_role; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_scope_mapping_role ON public.scope_mapping USING btree (role_id);\n\n\n--\n-- Name: idx_scope_policy_policy; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_scope_policy_policy ON public.scope_policy USING btree (policy_id);\n\n\n--\n-- Name: idx_update_time; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_update_time ON public.migration_model USING btree (update_time);\n\n\n--\n-- Name: idx_us_sess_id_on_cl_sess; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_us_sess_id_on_cl_sess ON public.offline_client_session USING btree (user_session_id);\n\n\n--\n-- Name: idx_usconsent_clscope; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_usconsent_clscope ON public.user_consent_client_scope USING btree (user_consent_id);\n\n\n--\n-- Name: idx_user_attribute; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_user_attribute ON public.user_attribute USING btree (user_id);\n\n\n--\n-- Name: idx_user_attribute_name; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_user_attribute_name ON public.user_attribute USING btree (name, value);\n\n\n--\n-- Name: idx_user_consent; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_user_consent ON public.user_consent USING btree (user_id);\n\n\n--\n-- Name: idx_user_credential; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_user_credential ON public.credential USING btree (user_id);\n\n\n--\n-- Name: idx_user_email; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_user_email ON public.user_entity USING btree (email);\n\n\n--\n-- Name: idx_user_group_mapping; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_user_group_mapping ON public.user_group_membership USING btree (user_id);\n\n\n--\n-- Name: idx_user_reqactions; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_user_reqactions ON public.user_required_action USING btree (user_id);\n\n\n--\n-- Name: idx_user_role_mapping; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_user_role_mapping ON public.user_role_mapping USING btree (user_id);\n\n\n--\n-- Name: idx_user_service_account; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_user_service_account ON public.user_entity USING btree (realm_id, service_account_client_link);\n\n\n--\n-- Name: idx_usr_fed_map_fed_prv; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_usr_fed_map_fed_prv ON public.user_federation_mapper USING btree (federation_provider_id);\n\n\n--\n-- Name: idx_usr_fed_map_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_usr_fed_map_realm ON public.user_federation_mapper USING btree (realm_id);\n\n\n--\n-- Name: idx_usr_fed_prv_realm; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_usr_fed_prv_realm ON public.user_federation_provider USING btree (realm_id);\n\n\n--\n-- Name: idx_web_orig_client; Type: INDEX; Schema: public; Owner: keycloak\n--\n\nCREATE INDEX idx_web_orig_client ON public.web_origins USING btree (client_id);\n\n\n--\n-- Name: client_session_auth_status auth_status_constraint; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_session_auth_status\n    ADD CONSTRAINT auth_status_constraint FOREIGN KEY (client_session) REFERENCES public.client_session(id);\n\n\n--\n-- Name: identity_provider fk2b4ebc52ae5c3b34; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.identity_provider\n    ADD CONSTRAINT fk2b4ebc52ae5c3b34 FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: client_attributes fk3c47c64beacca966; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_attributes\n    ADD CONSTRAINT fk3c47c64beacca966 FOREIGN KEY (client_id) REFERENCES public.client(id);\n\n\n--\n-- Name: federated_identity fk404288b92ef007a6; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.federated_identity\n    ADD CONSTRAINT fk404288b92ef007a6 FOREIGN KEY (user_id) REFERENCES public.user_entity(id);\n\n\n--\n-- Name: client_node_registrations fk4129723ba992f594; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_node_registrations\n    ADD CONSTRAINT fk4129723ba992f594 FOREIGN KEY (client_id) REFERENCES public.client(id);\n\n\n--\n-- Name: client_session_note fk5edfb00ff51c2736; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_session_note\n    ADD CONSTRAINT fk5edfb00ff51c2736 FOREIGN KEY (client_session) REFERENCES public.client_session(id);\n\n\n--\n-- Name: user_session_note fk5edfb00ff51d3472; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_session_note\n    ADD CONSTRAINT fk5edfb00ff51d3472 FOREIGN KEY (user_session) REFERENCES public.user_session(id);\n\n\n--\n-- Name: client_session_role fk_11b7sgqw18i532811v7o2dv76; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_session_role\n    ADD CONSTRAINT fk_11b7sgqw18i532811v7o2dv76 FOREIGN KEY (client_session) REFERENCES public.client_session(id);\n\n\n--\n-- Name: redirect_uris fk_1burs8pb4ouj97h5wuppahv9f; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.redirect_uris\n    ADD CONSTRAINT fk_1burs8pb4ouj97h5wuppahv9f FOREIGN KEY (client_id) REFERENCES public.client(id);\n\n\n--\n-- Name: user_federation_provider fk_1fj32f6ptolw2qy60cd8n01e8; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_federation_provider\n    ADD CONSTRAINT fk_1fj32f6ptolw2qy60cd8n01e8 FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: client_session_prot_mapper fk_33a8sgqw18i532811v7o2dk89; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_session_prot_mapper\n    ADD CONSTRAINT fk_33a8sgqw18i532811v7o2dk89 FOREIGN KEY (client_session) REFERENCES public.client_session(id);\n\n\n--\n-- Name: realm_required_credential fk_5hg65lybevavkqfki3kponh9v; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_required_credential\n    ADD CONSTRAINT fk_5hg65lybevavkqfki3kponh9v FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: resource_attribute fk_5hrm2vlf9ql5fu022kqepovbr; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_attribute\n    ADD CONSTRAINT fk_5hrm2vlf9ql5fu022kqepovbr FOREIGN KEY (resource_id) REFERENCES public.resource_server_resource(id);\n\n\n--\n-- Name: user_attribute fk_5hrm2vlf9ql5fu043kqepovbr; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_attribute\n    ADD CONSTRAINT fk_5hrm2vlf9ql5fu043kqepovbr FOREIGN KEY (user_id) REFERENCES public.user_entity(id);\n\n\n--\n-- Name: user_required_action fk_6qj3w1jw9cvafhe19bwsiuvmd; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_required_action\n    ADD CONSTRAINT fk_6qj3w1jw9cvafhe19bwsiuvmd FOREIGN KEY (user_id) REFERENCES public.user_entity(id);\n\n\n--\n-- Name: keycloak_role fk_6vyqfe4cn4wlq8r6kt5vdsj5c; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.keycloak_role\n    ADD CONSTRAINT fk_6vyqfe4cn4wlq8r6kt5vdsj5c FOREIGN KEY (realm) REFERENCES public.realm(id);\n\n\n--\n-- Name: realm_smtp_config fk_70ej8xdxgxd0b9hh6180irr0o; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_smtp_config\n    ADD CONSTRAINT fk_70ej8xdxgxd0b9hh6180irr0o FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: realm_attribute fk_8shxd6l3e9atqukacxgpffptw; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_attribute\n    ADD CONSTRAINT fk_8shxd6l3e9atqukacxgpffptw FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: composite_role fk_a63wvekftu8jo1pnj81e7mce2; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.composite_role\n    ADD CONSTRAINT fk_a63wvekftu8jo1pnj81e7mce2 FOREIGN KEY (composite) REFERENCES public.keycloak_role(id);\n\n\n--\n-- Name: authentication_execution fk_auth_exec_flow; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.authentication_execution\n    ADD CONSTRAINT fk_auth_exec_flow FOREIGN KEY (flow_id) REFERENCES public.authentication_flow(id);\n\n\n--\n-- Name: authentication_execution fk_auth_exec_realm; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.authentication_execution\n    ADD CONSTRAINT fk_auth_exec_realm FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: authentication_flow fk_auth_flow_realm; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.authentication_flow\n    ADD CONSTRAINT fk_auth_flow_realm FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: authenticator_config fk_auth_realm; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.authenticator_config\n    ADD CONSTRAINT fk_auth_realm FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: client_session fk_b4ao2vcvat6ukau74wbwtfqo1; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_session\n    ADD CONSTRAINT fk_b4ao2vcvat6ukau74wbwtfqo1 FOREIGN KEY (session_id) REFERENCES public.user_session(id);\n\n\n--\n-- Name: user_role_mapping fk_c4fqv34p1mbylloxang7b1q3l; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_role_mapping\n    ADD CONSTRAINT fk_c4fqv34p1mbylloxang7b1q3l FOREIGN KEY (user_id) REFERENCES public.user_entity(id);\n\n\n--\n-- Name: client_scope_attributes fk_cl_scope_attr_scope; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_scope_attributes\n    ADD CONSTRAINT fk_cl_scope_attr_scope FOREIGN KEY (scope_id) REFERENCES public.client_scope(id);\n\n\n--\n-- Name: client_scope_role_mapping fk_cl_scope_rm_scope; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_scope_role_mapping\n    ADD CONSTRAINT fk_cl_scope_rm_scope FOREIGN KEY (scope_id) REFERENCES public.client_scope(id);\n\n\n--\n-- Name: client_user_session_note fk_cl_usr_ses_note; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_user_session_note\n    ADD CONSTRAINT fk_cl_usr_ses_note FOREIGN KEY (client_session) REFERENCES public.client_session(id);\n\n\n--\n-- Name: protocol_mapper fk_cli_scope_mapper; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.protocol_mapper\n    ADD CONSTRAINT fk_cli_scope_mapper FOREIGN KEY (client_scope_id) REFERENCES public.client_scope(id);\n\n\n--\n-- Name: client_initial_access fk_client_init_acc_realm; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.client_initial_access\n    ADD CONSTRAINT fk_client_init_acc_realm FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: component_config fk_component_config; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.component_config\n    ADD CONSTRAINT fk_component_config FOREIGN KEY (component_id) REFERENCES public.component(id);\n\n\n--\n-- Name: component fk_component_realm; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.component\n    ADD CONSTRAINT fk_component_realm FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: realm_default_groups fk_def_groups_realm; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_default_groups\n    ADD CONSTRAINT fk_def_groups_realm FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: user_federation_mapper_config fk_fedmapper_cfg; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_federation_mapper_config\n    ADD CONSTRAINT fk_fedmapper_cfg FOREIGN KEY (user_federation_mapper_id) REFERENCES public.user_federation_mapper(id);\n\n\n--\n-- Name: user_federation_mapper fk_fedmapperpm_fedprv; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_federation_mapper\n    ADD CONSTRAINT fk_fedmapperpm_fedprv FOREIGN KEY (federation_provider_id) REFERENCES public.user_federation_provider(id);\n\n\n--\n-- Name: user_federation_mapper fk_fedmapperpm_realm; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_federation_mapper\n    ADD CONSTRAINT fk_fedmapperpm_realm FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: associated_policy fk_frsr5s213xcx4wnkog82ssrfy; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.associated_policy\n    ADD CONSTRAINT fk_frsr5s213xcx4wnkog82ssrfy FOREIGN KEY (associated_policy_id) REFERENCES public.resource_server_policy(id);\n\n\n--\n-- Name: scope_policy fk_frsrasp13xcx4wnkog82ssrfy; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.scope_policy\n    ADD CONSTRAINT fk_frsrasp13xcx4wnkog82ssrfy FOREIGN KEY (policy_id) REFERENCES public.resource_server_policy(id);\n\n\n--\n-- Name: resource_server_perm_ticket fk_frsrho213xcx4wnkog82sspmt; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_perm_ticket\n    ADD CONSTRAINT fk_frsrho213xcx4wnkog82sspmt FOREIGN KEY (resource_server_id) REFERENCES public.resource_server(id);\n\n\n--\n-- Name: resource_server_resource fk_frsrho213xcx4wnkog82ssrfy; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_resource\n    ADD CONSTRAINT fk_frsrho213xcx4wnkog82ssrfy FOREIGN KEY (resource_server_id) REFERENCES public.resource_server(id);\n\n\n--\n-- Name: resource_server_perm_ticket fk_frsrho213xcx4wnkog83sspmt; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_perm_ticket\n    ADD CONSTRAINT fk_frsrho213xcx4wnkog83sspmt FOREIGN KEY (resource_id) REFERENCES public.resource_server_resource(id);\n\n\n--\n-- Name: resource_server_perm_ticket fk_frsrho213xcx4wnkog84sspmt; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_perm_ticket\n    ADD CONSTRAINT fk_frsrho213xcx4wnkog84sspmt FOREIGN KEY (scope_id) REFERENCES public.resource_server_scope(id);\n\n\n--\n-- Name: associated_policy fk_frsrpas14xcx4wnkog82ssrfy; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.associated_policy\n    ADD CONSTRAINT fk_frsrpas14xcx4wnkog82ssrfy FOREIGN KEY (policy_id) REFERENCES public.resource_server_policy(id);\n\n\n--\n-- Name: scope_policy fk_frsrpass3xcx4wnkog82ssrfy; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.scope_policy\n    ADD CONSTRAINT fk_frsrpass3xcx4wnkog82ssrfy FOREIGN KEY (scope_id) REFERENCES public.resource_server_scope(id);\n\n\n--\n-- Name: resource_server_perm_ticket fk_frsrpo2128cx4wnkog82ssrfy; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_perm_ticket\n    ADD CONSTRAINT fk_frsrpo2128cx4wnkog82ssrfy FOREIGN KEY (policy_id) REFERENCES public.resource_server_policy(id);\n\n\n--\n-- Name: resource_server_policy fk_frsrpo213xcx4wnkog82ssrfy; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_policy\n    ADD CONSTRAINT fk_frsrpo213xcx4wnkog82ssrfy FOREIGN KEY (resource_server_id) REFERENCES public.resource_server(id);\n\n\n--\n-- Name: resource_scope fk_frsrpos13xcx4wnkog82ssrfy; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_scope\n    ADD CONSTRAINT fk_frsrpos13xcx4wnkog82ssrfy FOREIGN KEY (resource_id) REFERENCES public.resource_server_resource(id);\n\n\n--\n-- Name: resource_policy fk_frsrpos53xcx4wnkog82ssrfy; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_policy\n    ADD CONSTRAINT fk_frsrpos53xcx4wnkog82ssrfy FOREIGN KEY (resource_id) REFERENCES public.resource_server_resource(id);\n\n\n--\n-- Name: resource_policy fk_frsrpp213xcx4wnkog82ssrfy; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_policy\n    ADD CONSTRAINT fk_frsrpp213xcx4wnkog82ssrfy FOREIGN KEY (policy_id) REFERENCES public.resource_server_policy(id);\n\n\n--\n-- Name: resource_scope fk_frsrps213xcx4wnkog82ssrfy; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_scope\n    ADD CONSTRAINT fk_frsrps213xcx4wnkog82ssrfy FOREIGN KEY (scope_id) REFERENCES public.resource_server_scope(id);\n\n\n--\n-- Name: resource_server_scope fk_frsrso213xcx4wnkog82ssrfy; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_server_scope\n    ADD CONSTRAINT fk_frsrso213xcx4wnkog82ssrfy FOREIGN KEY (resource_server_id) REFERENCES public.resource_server(id);\n\n\n--\n-- Name: composite_role fk_gr7thllb9lu8q4vqa4524jjy8; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.composite_role\n    ADD CONSTRAINT fk_gr7thllb9lu8q4vqa4524jjy8 FOREIGN KEY (child_role) REFERENCES public.keycloak_role(id);\n\n\n--\n-- Name: user_consent_client_scope fk_grntcsnt_clsc_usc; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_consent_client_scope\n    ADD CONSTRAINT fk_grntcsnt_clsc_usc FOREIGN KEY (user_consent_id) REFERENCES public.user_consent(id);\n\n\n--\n-- Name: user_consent fk_grntcsnt_user; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_consent\n    ADD CONSTRAINT fk_grntcsnt_user FOREIGN KEY (user_id) REFERENCES public.user_entity(id);\n\n\n--\n-- Name: group_attribute fk_group_attribute_group; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.group_attribute\n    ADD CONSTRAINT fk_group_attribute_group FOREIGN KEY (group_id) REFERENCES public.keycloak_group(id);\n\n\n--\n-- Name: group_role_mapping fk_group_role_group; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.group_role_mapping\n    ADD CONSTRAINT fk_group_role_group FOREIGN KEY (group_id) REFERENCES public.keycloak_group(id);\n\n\n--\n-- Name: realm_enabled_event_types fk_h846o4h0w8epx5nwedrf5y69j; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_enabled_event_types\n    ADD CONSTRAINT fk_h846o4h0w8epx5nwedrf5y69j FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: realm_events_listeners fk_h846o4h0w8epx5nxev9f5y69j; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_events_listeners\n    ADD CONSTRAINT fk_h846o4h0w8epx5nxev9f5y69j FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: identity_provider_mapper fk_idpm_realm; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.identity_provider_mapper\n    ADD CONSTRAINT fk_idpm_realm FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: idp_mapper_config fk_idpmconfig; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.idp_mapper_config\n    ADD CONSTRAINT fk_idpmconfig FOREIGN KEY (idp_mapper_id) REFERENCES public.identity_provider_mapper(id);\n\n\n--\n-- Name: web_origins fk_lojpho213xcx4wnkog82ssrfy; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.web_origins\n    ADD CONSTRAINT fk_lojpho213xcx4wnkog82ssrfy FOREIGN KEY (client_id) REFERENCES public.client(id);\n\n\n--\n-- Name: scope_mapping fk_ouse064plmlr732lxjcn1q5f1; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.scope_mapping\n    ADD CONSTRAINT fk_ouse064plmlr732lxjcn1q5f1 FOREIGN KEY (client_id) REFERENCES public.client(id);\n\n\n--\n-- Name: protocol_mapper fk_pcm_realm; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.protocol_mapper\n    ADD CONSTRAINT fk_pcm_realm FOREIGN KEY (client_id) REFERENCES public.client(id);\n\n\n--\n-- Name: credential fk_pfyr0glasqyl0dei3kl69r6v0; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.credential\n    ADD CONSTRAINT fk_pfyr0glasqyl0dei3kl69r6v0 FOREIGN KEY (user_id) REFERENCES public.user_entity(id);\n\n\n--\n-- Name: protocol_mapper_config fk_pmconfig; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.protocol_mapper_config\n    ADD CONSTRAINT fk_pmconfig FOREIGN KEY (protocol_mapper_id) REFERENCES public.protocol_mapper(id);\n\n\n--\n-- Name: default_client_scope fk_r_def_cli_scope_realm; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.default_client_scope\n    ADD CONSTRAINT fk_r_def_cli_scope_realm FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: required_action_provider fk_req_act_realm; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.required_action_provider\n    ADD CONSTRAINT fk_req_act_realm FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: resource_uris fk_resource_server_uris; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.resource_uris\n    ADD CONSTRAINT fk_resource_server_uris FOREIGN KEY (resource_id) REFERENCES public.resource_server_resource(id);\n\n\n--\n-- Name: role_attribute fk_role_attribute_id; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.role_attribute\n    ADD CONSTRAINT fk_role_attribute_id FOREIGN KEY (role_id) REFERENCES public.keycloak_role(id);\n\n\n--\n-- Name: realm_supported_locales fk_supported_locales_realm; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.realm_supported_locales\n    ADD CONSTRAINT fk_supported_locales_realm FOREIGN KEY (realm_id) REFERENCES public.realm(id);\n\n\n--\n-- Name: user_federation_config fk_t13hpu1j94r2ebpekr39x5eu5; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_federation_config\n    ADD CONSTRAINT fk_t13hpu1j94r2ebpekr39x5eu5 FOREIGN KEY (user_federation_provider_id) REFERENCES public.user_federation_provider(id);\n\n\n--\n-- Name: user_group_membership fk_user_group_user; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.user_group_membership\n    ADD CONSTRAINT fk_user_group_user FOREIGN KEY (user_id) REFERENCES public.user_entity(id);\n\n\n--\n-- Name: policy_config fkdc34197cf864c4e43; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.policy_config\n    ADD CONSTRAINT fkdc34197cf864c4e43 FOREIGN KEY (policy_id) REFERENCES public.resource_server_policy(id);\n\n\n--\n-- Name: identity_provider_config fkdc4897cf864c4e43; Type: FK CONSTRAINT; Schema: public; Owner: keycloak\n--\n\nALTER TABLE ONLY public.identity_provider_config\n    ADD CONSTRAINT fkdc4897cf864c4e43 FOREIGN KEY (identity_provider_id) REFERENCES public.identity_provider(internal_id);\n\n\n--\n-- PostgreSQL database dump complete\n--\n\n"
  },
  {
    "path": "lint-staged.config.js",
    "content": "module.exports = {\n    \"**/*.{js,jsx,json,ts,tsx,md,yaml,css,less,scss,html}\": [\"prettier --write\"],\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"rboard\",\n  \"version\": \"1.0.0\",\n  \"main\": \"index.js\",\n  \"license\": \"MIT\",\n  \"dependencies\": {\n    \"@types/lodash\": \"^4.14.164\",\n    \"lodash\": \"^4.17.20\",\n    \"uuid\": \"^8.3.0\"\n  },\n  \"devDependencies\": {\n    \"@playwright/test\": \"^1.51.1\",\n    \"@types/uuid\": \"^8.3.0\",\n    \"husky\": \"4\",\n    \"lint-staged\": \"^10.5.4\",\n    \"npm-run-all\": \"^4.1.5\",\n    \"prettier\": \"^2.2.1\",\n    \"tsx\": \"3.13.0\",\n    \"typescript\": \"^5.3\",\n    \"vitest\": \"^1.3.1\"\n  },\n  \"private\": true,\n  \"engines\": {\n    \"node\": \">=18 <21\"\n  },\n  \"workspaces\": [\n    \"frontend\",\n    \"backend\",\n    \"perf-tester\"\n  ],\n  \"scripts\": {\n    \"dev\": \"npm-run-all --parallel dev:db watch:frontend dev:backend typecheck:dev:frontend\",\n    \"dev-with-keycloak\": \"npm-run-all --parallel dev:keycloak dev\",\n    \"dev:no-db\": \"npm-run-all --parallel watch:frontend dev:backend\",\n    \"dev:db\": \"docker-compose up db||true\",\n    \"dev:keycloak\": \"docker-compose up keycloak||true\",\n    \"watch:frontend\": \"yarn --cwd frontend watch\",\n    \"dev:backend\": \"yarn --cwd backend dev\",\n    \"start\": \"yarn start:backend\",\n    \"start:backend\": \"yarn --cwd backend start\",\n    \"benchmark\": \"tsx benchmark/benchmark.ts\",\n    \"build\": \"yarn build:frontend && yarn build:backend && yarn build:perf-tester\",\n    \"build:frontend\": \"yarn --cwd frontend build\",\n    \"build:backend\": \"yarn --cwd backend build\",\n    \"build:perf-tester\": \"yarn --cwd perf-tester build\",\n    \"test:unit\": \"vitest --run\",\n    \"test:unit:watch\": \"vitest\",\n    \"test:integration\": \"TEST_TARGET=integration vitest --run\",\n    \"test:integration:watch\": \"TEST_TARGET=integration vitest\",\n    \"test:playwright\": \"yarn playwright test\",\n    \"test:playwright:ui\": \"yarn playwright test --ui&\",\n    \"test:playwright:debug\": \"PWDEBUG=1 yarn playwright test\",\n    \"prettier:check\": \"prettier --check .\",\n    \"lint\": \"yarn prettier:check && yarn --cwd backend lint\",\n    \"typecheck:dev:frontend\": \"cd frontend && tsc --noEmit --preserveWatchOutput --watch\",\n    \"perf-test:prod\": \"node perf-tester/dist/perf-tester/src/index.js\",\n    \"format\": \"prettier --write .\",\n\n    \"wait-for-db\": \"cd backend && tsx src/tools/wait-for-db.ts\",\n    \"psql\": \"psql postgres://r-board:secret@localhost:13338/r-board\"\n  }\n}\n"
  },
  {
    "path": "perf-tester/README.md",
    "content": "Ourboard perftester. `yarn dev`. See src/index.ts for supported ENV vars.\n"
  },
  {
    "path": "perf-tester/package.json",
    "content": "{\n  \"name\": \"rboard-perf-tester\",\n  \"version\": \"1.0.0\",\n  \"main\": \"dist/perf-tester/src/index.js\",\n  \"license\": \"MIT\",\n  \"dependencies\": {\n    \"@types/cookie\": \"^0.4.0\",\n    \"@types/email-validator\": \"^1.0.6\",\n    \"@types/lodash\": \"^4.14.161\",\n    \"@types/md5\": \"^2.2.1\",\n    \"@types/node-fetch\": \"^2.5.9\",\n    \"@types/pretty-ms\": \"^5.0.1\",\n    \"cookie\": \"^0.4.1\",\n    \"email-validator\": \"^2.0.4\",\n    \"harmaja\": \"^0.24\",\n    \"jsonwebtoken\": \"^8.5.1\",\n    \"lodash\": \"^4.17.20\",\n    \"lonna\": \"^0.12.2\",\n    \"material-design-icons-iconfont\": \"^6.1.0\",\n    \"md5\": \"^2.3.0\",\n    \"node-fetch\": \"^2.6.1\",\n    \"pretty-ms\": \"^7.0.1\",\n    \"uuid\": \"^8.3.0\",\n    \"ws\": \"^7.4.4\"\n  },\n  \"scripts\": {\n    \"watch\": \"tsc --watch --noEmit\",\n    \"build\": \"tsc\",\n    \"start\": \"node .\",\n    \"create-boards\": \"tsx src/create-boards.ts\",\n    \"dev\": \"tsc-watch --onSuccess \\\"node .\\\" --preserveWatchOutput\"\n  },\n  \"devDependencies\": {\n    \"@types/jsonwebtoken\": \"^8.5.0\",\n    \"@types/uuid\": \"^8.3.0\",\n    \"cssnano\": \"^4.1.10\",\n    \"nodemon\": \"^2.0.4\",\n    \"npm-run-all\": \"^4.1.5\",\n    \"tsc-watch\": \"^4.2.9\",\n    \"typescript\": \"^4.0.2\"\n  }\n}\n"
  },
  {
    "path": "perf-tester/src/create-boards.ts",
    "content": "import fetch from \"node-fetch\"\nimport _ from \"lodash\"\n\nconst BOARD_COUNT = parseInt(process.env.BOARD_COUNT || \"1\")\nconst DOMAIN = process.env.DOMAIN\nconst API_ROOT = `${DOMAIN ? \"https\" : \"http\"}://${DOMAIN ?? \"localhost:1337\"}`\nconst CREATE_BOARD_API = `${API_ROOT}/api/v1/board`\n\nasync function createBoardAndReturnId(n: number) {\n    const result = await fetch(CREATE_BOARD_API, {\n        method: \"POST\",\n        body: JSON.stringify({ name: \"perftest\" + n }),\n        headers: {\n            \"content-type\": \"application/json\",\n        },\n    })\n    const body = await result.json()\n    return body.id as string\n}\n\nconst promises = _.range(1, BOARD_COUNT + 1).map(createBoardAndReturnId)\nPromise.all(promises).then((ids) => {\n    console.log(ids.join(\",\"))\n})\n"
  },
  {
    "path": "perf-tester/src/index.ts",
    "content": "import dotenv from \"dotenv\"\ndotenv.config({ path: \"../backend/.env\" })\n\nimport _ from \"lodash\"\nimport * as L from \"lonna\"\nimport WebSocket from \"ws\"\nimport { NOTE_COLORS } from \"../../common/src/colors\"\nimport {\n    CrdtEnabled,\n    Point,\n    UIEvent,\n    defaultBoardSize,\n    isNote,\n    isPersistableBoardItemEvent,\n    isText,\n    newNote,\n    newText,\n} from \"../../common/src/domain\"\nimport { CRDTStore } from \"../../frontend/src/store/crdt-store\"\nimport { GenericServerConnection } from \"../../frontend/src/store/server-connection\"\n\n// hack, sue me\n// @ts-ignore\nglobal.localStorage = {}\n\nfunction add(a: Point, b: Point) {\n    return { x: a.x + b.x, y: a.y + b.y }\n}\n\nfunction createTester(nickname: string, boardId: string) {\n    let counter = 0\n    const { width, height } = defaultBoardSize\n    const center = { x: width / 2 - 30 + Math.random() * 60, y: height / 2 - 20 + Math.random() * 40 }\n    const radius = 10 + Math.random() * 10\n    const increment = Math.random() * 4 - 2\n    const WS_ROOT = `${DOMAIN ? \"wss\" : \"ws\"}://${DOMAIN ?? \"localhost:1337\"}`\n    const WS_ADDRESS = `${WS_ROOT}/socket/board/${boardId}`\n\n    let connection = GenericServerConnection(L.constant(WS_ADDRESS), L.constant(false), (s) => new WebSocket(s) as any)\n    let sessionId = \"\"\n    connection.bufferedServerEvents.forEach((event) => {\n        if (event.action === \"board.join.ack\") {\n            console.log(\"Got session id\", sessionId)\n            sessionId = event.sessionId\n        }\n    })\n    const localEvents = L.bus<UIEvent>()\n    localEvents.forEach(connection.send)\n\n    class MyWebSocket extends WebSocket {\n        constructor(url: string | URL, protocols?: string | string[] | undefined) {\n            console.log(\"Creating websocket\", url, protocols)\n            super(url as any, protocols, { headers: { Cookie: `sessionId=${sessionId}` } })\n        }\n    }\n\n    const crdtStore = CRDTStore(\n        L.constant(boardId),\n        connection.connected,\n        localEvents.pipe(L.filter(isPersistableBoardItemEvent)).applyScope(L.globalScope),\n        L.constant({\n            status: \"anonymous\",\n            sessionId: null,\n            nickname: nickname,\n            nicknameSetByUser: true,\n            loginSupported: false,\n        }),\n        () => WS_ROOT,\n        MyWebSocket as any,\n    )\n    crdtStore.getBoardCrdt(boardId)\n\n    connection.connected\n        .pipe(\n            L.changes,\n            L.filter((c) => c),\n        )\n        .forEach(() => {\n            localEvents.push({ action: \"board.join\", boardId })\n        })\n    connection.bufferedServerEvents.forEach((event) => {\n        if (event.action === \"board.init\" && \"board\" in event) {\n            const boardAtInit = event.board\n            const notes = Object.values(boardAtInit.items).filter(isNote)\n            const texts = Object.values(boardAtInit.items).filter(isText)\n            setInterval(() => {\n                counter += increment\n                const position = add(center, {\n                    x: radius * Math.sin(counter / 100),\n                    y: radius * Math.cos(counter / 100),\n                })\n                localEvents.push({ action: \"cursor.move\", position, boardId })\n                if (Math.random() < notesPerInterval) {\n                    const note = newNote(\"NOTE \" + counter, \"black\", position.x, position.y)\n                    notes.push(note)\n                    localEvents.push({\n                        action: \"item.add\",\n                        boardId,\n                        items: [note],\n                        connections: [],\n                    })\n                }\n                if (Math.random() < textsPerInterval) {\n                    const text = newText(CrdtEnabled, \"TEXT \" + counter, position.x, position.y, 5, 5)\n                    localEvents.push({\n                        action: \"item.add\",\n                        boardId,\n                        items: [text],\n                        connections: [],\n                    })\n                }\n                // TODO: add crdt edits\n                if (Math.random() < editsPerInterval && notes.length > 0) {\n                    const target = _.sample(notes)!\n                    if (!target) {\n                        throw Error(\"Target item not found\")\n                    }\n                    const updated = { ...target, text: \"EDIT \" + counter, color: _.sample(NOTE_COLORS)?.color! }\n                    connection.send({\n                        ackId: \"perf\",\n                        events: [\n                            {\n                                action: \"item.front\",\n                                boardId,\n                                itemIds: [updated.id],\n                            },\n                            {\n                                action: \"item.update\",\n                                boardId,\n                                items: [updated],\n                            },\n                        ],\n                    })\n                }\n            }, interval)\n        }\n        if (event.action === \"board.join.ack\") {\n            localEvents.push({ action: \"nickname.set\", nickname })\n        }\n    })\n}\n\n// Environment variables.\nconst USER_COUNT = parseInt(process.env.USER_COUNT ?? \"10\")\nconst BOARD_ID = process.env.BOARD_ID\nif (!BOARD_ID) {\n    throw Error(\"BOARD_ID missing. Please specify one more board ids separated by comma (,)\")\n}\nconst BOARD_IDS = BOARD_ID.split(\",\")\nconst DOMAIN = process.env.DOMAIN\n\nconst NOTES_PER_SEC = parseFloat(process.env.NOTES_PER_SEC ?? \"0.1\")\nconst TEXTS_PER_SEC = parseFloat(process.env.TEXTS_PER_SEC ?? \"0.0\")\nconst EDITS_PER_SEC = parseFloat(process.env.EDITS_PER_SEC ?? \"0\")\nconst CURSOR_MOVES_PER_SEC = parseFloat(process.env.CURSOR_MOVES_PER_SEC ?? \"10\")\n\n// Calculated vars\nconst interval = 1000 / CURSOR_MOVES_PER_SEC\nconst notesPerInterval = (NOTES_PER_SEC / 1000) * interval\nconst textsPerInterval = (TEXTS_PER_SEC / 1000) * interval\nconst editsPerInterval = (EDITS_PER_SEC / 1000) * interval\nconsole.log(\n    `Starting ${USER_COUNT} testers, moving cursors ${CURSOR_MOVES_PER_SEC}/sec, creating notes ${NOTES_PER_SEC}`,\n)\nconsole.log(`Total cursor events ${CURSOR_MOVES_PER_SEC * USER_COUNT}/s`)\nconsole.log(`Total creation events ${NOTES_PER_SEC * USER_COUNT}/s`)\n\nfor (let i = 0; i < USER_COUNT; i++) {\n    createTester(\"perf-tester-\" + (i + 1), BOARD_IDS[i % BOARD_IDS.length])\n}\n"
  },
  {
    "path": "perf-tester/tsconfig.json",
    "content": "{\n    \"extends\": \"../tsconfig\",\n    \"compilerOptions\": {\n        \"module\": \"commonjs\",\n        \"outDir\": \"dist\"\n    }\n}\n"
  },
  {
    "path": "playwright/src/pages/BoardApi.ts",
    "content": "import { Page, expect, test } from \"@playwright/test\"\n\nexport function BoardApi(page: Page) {\n    return {\n        async getBoard(accessToken: string, boardId: string) {\n            const response = await page.request.get(`/api/v1/board/${boardId}`, {\n                headers: {\n                    API_TOKEN: accessToken,\n                },\n            })\n            return response.json()\n        },\n        async getBoardHierarchy(accessToken: string, boardId: string) {\n            const response = await page.request.get(`/api/v1/board/${boardId}/hierarchy`, {\n                headers: {\n                    API_TOKEN: accessToken,\n                },\n            })\n            return response.json()\n        },\n        async getBoardHistory(accessToken: string, boardId: string) {\n            const response = await page.request.get(`/api/v1/board/${boardId}/history`, {\n                headers: {\n                    API_TOKEN: accessToken,\n                },\n            })\n            return response.json()\n        },\n        async getBoardCsv(accessToken: string, boardId: string) {\n            const response = await page.request.get(`/api/v1/board/${boardId}/csv`, {\n                headers: {\n                    API_TOKEN: accessToken,\n                },\n            })\n            return response.text()\n        },\n        async createNote(accessToken: string, boardId: string, text: string, attributes?: object) {\n            return await test.step(\"Add item \" + text, async () => {\n                const response = await page.request.post(`/api/v1/board/${boardId}/item`, {\n                    data: {\n                        type: \"note\",\n                        text,\n                        color: \"#000000\",\n                        ...attributes,\n                    },\n                    headers: {\n                        API_TOKEN: accessToken,\n                    },\n                })\n                expect(response.status()).toEqual(200)\n                return await response.json()\n            })\n        },\n        async createBoard(data: any) {\n            const { id, accessToken } = await test.step(\"Create board\", async () => {\n                const response = await page.request.post(\"/api/v1/board\", {\n                    data,\n                })\n                return await response.json()\n            })\n            return { id, accessToken }\n        },\n        async updateBoard(accessToken: string, boardId: string, data: any) {\n            await test.step(\"Update board\", async () => {\n                const response = await page.request.put(`/api/v1/board/${boardId}`, {\n                    data,\n                    headers: {\n                        API_TOKEN: accessToken,\n                    },\n                })\n                expect(response.status()).toEqual(200)\n            })\n        },\n        async updateItem(accessToken: string, boardId: string, itemId: string, data: any) {\n            await test.step(\"Update item\", async () => {\n                const response = await page.request.put(`/api/v1/board/${boardId}/item/${itemId}`, {\n                    data,\n                    headers: {\n                        API_TOKEN: accessToken,\n                    },\n                })\n                expect(response.status()).toEqual(200)\n            })\n        },\n    }\n}\n"
  },
  {
    "path": "playwright/src/pages/BoardPage.ts",
    "content": "import { Browser, Locator, Page, expect, selectors, test } from \"@playwright/test\"\nimport { DashboardPage, navigateToDashboard } from \"./DashboardPage\"\nimport { sleep } from \"../../../common/src/sleep\"\nimport { assertNotNull } from \"../../../common/src/assertNotNull\"\n\nexport async function navigateToBoard(page: Page, browser: Browser, boardId: string) {\n    selectors.setTestIdAttribute(\"data-test\")\n    await page.goto(\"http://localhost:1337/b/\" + boardId)\n    return BoardPage(page, browser)\n}\n\nexport type NewBoardOptions = Partial<{\n    boardName: string\n    useCRDT: boolean\n}>\n\nexport const navigateToNewBoard = (page: Page, browser: Browser, options?: NewBoardOptions) =>\n    test.step(\"Create new board\", async () => {\n        const dashboard = await navigateToDashboard(page, browser)\n        return await dashboard.createNewBoard(options)\n    })\n\nexport const semiUniqueId = () => {\n    const now = String(Date.now())\n    return now.substring(now.length - 5)\n}\n\nexport type BoardPage = ReturnType<typeof BoardPage>\nexport function BoardPage(page: Page, browser: Browser) {\n    const board = page.locator(\".online .board\")\n    const boardName = page.locator(\"#board-name\").locator(`[contentEditable]`)\n    const newNoteOnPalette = page.getByTestId(\"palette-new-note\")\n    const newTextOnPalette = page.getByTestId(\"palette-new-text\")\n    const newContainerOnPalette = page.getByTestId(\"palette-new-container\")\n    const scrollContainer = page.locator(`div [class=\"scroll-container\"]`)\n    async function waitForThrottle() {\n        await sleep(50) // for UI throttling to take effect\n    }\n\n    async function moveMouseTo(newPos: { x: number; y: number }) {\n        const clientPos = await itemToClientPos(newPos)\n        await page.mouse.move(clientPos.clientX, clientPos.clientY, { steps: 10 })\n        await waitForThrottle()\n    }\n\n    async function itemToClientPos(itemPos: { x: number; y: number }) {\n        const scrollContainer = page.locator(`div [class=\"scroll-container\"]`)\n        const scPos = assertNotNull(await scrollContainer.boundingBox())\n        return { clientX: itemPos.x + scPos.x, clientY: itemPos.y + scPos.y }\n    }\n\n    async function clientToElementPos(clientPos: { clientX: number; clientY: number }) {\n        const scrollContainer = page.locator(`div [class=\"scroll-container\"]`)\n        const scPos = assertNotNull(await scrollContainer.boundingBox())\n        return { x: Math.round(clientPos.clientX - scPos.x), y: Math.round(clientPos.clientY - scPos.y) }\n    }\n\n    async function getElementPosition(item: Locator) {\n        const scPos = assertNotNull(await scrollContainer.boundingBox())\n        const clientPos = assertNotNull(await item.boundingBox())\n        return await clientToElementPos({\n            clientX: clientPos.x + clientPos.width / 2,\n            clientY: clientPos.y + clientPos.height / 2,\n        })\n    }\n\n    async function getElementSize(item: Locator) {\n        const { width, height } = assertNotNull(await item.boundingBox())\n        return { width, height }\n    }\n\n    async function createNew(paletteItem: Locator, x: number, y: number) {\n        await expect(paletteItem).toBeVisible()\n        await paletteItem.dispatchEvent(\"dragstart\")\n        await paletteItem.dispatchEvent(\"dragover\")\n        await moveMouseTo({ x, y })\n        await paletteItem.dispatchEvent(\"dragend\")\n    }\n\n    async function dragElementOnBoard(element: Locator, x: number, y: number) {\n        return await test.step(`Drag item to (${x}, ${y})`, async () => {\n            const itemPos = await getElementPosition(element)\n            await moveMouseTo(itemPos)\n            await element.dispatchEvent(\"dragstart\", await itemToClientPos(itemPos))\n            await element.dispatchEvent(\"drag\")\n            page.locator(`.online .board`).dispatchEvent(\"dragover\", await itemToClientPos(itemPos))\n            await moveMouseTo({ x, y })\n            page.locator(`.online .board`).dispatchEvent(\"dragover\", await itemToClientPos({ x, y }))\n            await waitForThrottle()\n            await element.dispatchEvent(\"drag\")\n            await element.dispatchEvent(\"dragend\")\n        })\n    }\n\n    async function selectText(el: Locator, text: string) {\n        await test.step(\"Select text \" + text, async () => {\n            // below code selects the given word from the line. text is the word I want to //select\n            await el.evaluate((element, text: string) => {\n                const selection = window.getSelection()!\n                const content = (element as HTMLElement).innerText\n                const range = document.createRange()\n                const index = content.indexOf(text)\n                if (index === -1) {\n                    throw Error(`Text ${text} not found in ${content}`)\n                }\n                const textNode = element.firstChild!\n                console.log(\"Textnode\", textNode.textContent)\n                range.setStart(textNode, index)\n                console.log(\"Length\", (textNode.textContent as any).length)\n                range.setEnd(textNode, index + text.length)\n                selection.removeAllRanges()\n                selection.addRange(range)\n            }, text)\n        })\n    }\n\n    async function selectAll(el: Locator) {\n        const textToSelect = (await el.textContent()) ?? \"\"\n        selectText(el, textToSelect)\n    }\n\n    return {\n        page,\n        board,\n        scrollContainer,\n        newNoteOnPalette,\n        newTextOnPalette,\n        newContainerOnPalette,\n        cloneButton: page.locator(\".tool.duplicate\"),\n        getBoardId() {\n            return assertNotNull(page.url().split(\"/\").pop())\n        },\n        async assertBoardName(name: string) {\n            await expect(boardName).toHaveText(name)\n        },\n        async getBoardName() {\n            return assertNotNull(await boardName.textContent())\n        },\n        async renameBoard(name: string) {\n            await test.step(\"Rename board\", async () => {\n                await boardName.click()\n                await selectAll(boardName)\n                await boardName.pressSequentially(name)\n                await boardName.press(\"Enter\")\n                await this.assertBoardName(name)\n            })\n        },\n        async assertStatusMessage(message: string) {\n            await expect(page.locator(\".board-status-message\")).toHaveText(message)\n        },\n        async cloneBoard() {\n            await page.getByTitle(\"Make a copy\").click()\n        },\n        async goToDashBoard() {\n            await page.getByRole(\"link\", { name: \"All boards\" }).click()\n            return DashboardPage(page, browser)\n        },\n        async createNoteWithText(x: number, y: number, text: string) {\n            return await test.step(\"Create note \" + text, async () => {\n                await createNew(this.newNoteOnPalette, x, y)\n                const elt = page.locator(\".note.selected .editable\")\n                await expect(elt).toBeVisible()\n                await elt.fill(text)\n                await page.keyboard.press(\"Escape\")\n                await expect(this.getNote(text)).toBeVisible()\n                const result = this.getNote(text)\n                await expect(result).toHaveText(text)\n                return result\n            })\n        },\n        async createText(x: number, y: number, text: string) {\n            return await test.step(\"Create text \" + text, async () => {\n                await createNew(this.newTextOnPalette, x, y)\n                await this.getText(\"HELLO\")\n                    .locator(\".text\")\n                    .click({ position: { x: 5, y: 5 } })\n                await page.keyboard.press(\"Delete\")\n                await page.keyboard.press(\"Delete\")\n                await page.keyboard.press(\"Delete\")\n                await page.keyboard.press(\"Delete\")\n                await page.keyboard.press(\"Delete\")\n                await page.keyboard.type(`${text}`)\n                await expect(this.getText(text)).toBeVisible()\n                const result = this.getText(text)\n                await expect(result).toHaveText(text)\n                return result\n            })\n        },\n        async createArea(x: number, y: number, text: string) {\n            return await test.step(\"Create area \" + text, async () => {\n                await createNew(this.newContainerOnPalette, x, y)\n                await this.getArea(\"Unnamed area\").locator(\".text\").dblclick()\n                await page.locator(\"*:focus\").fill(text)\n                await expect(this.getArea(text)).toBeVisible()\n                const result = this.getArea(text)\n                await expect(result).toHaveText(text)\n                return result\n            })\n        },\n        async dragItem(item: Locator, x: number, y: number) {\n            await dragElementOnBoard(item, x, y)\n        },\n        async dragSelectionBottomCorner(x: number, y: number) {\n            const bottomCorner = board.locator(\".corner-resize-drag.bottom.right\")\n            await dragElementOnBoard(bottomCorner, x, y)\n        },\n        getNote(name: string) {\n            return page.locator(`.board > .note`).filter({ hasText: name })\n        },\n        getText(name: string) {\n            return page.locator(`.board > .text`).filter({ hasText: name })\n        },\n        getArea(name: string) {\n            return page.locator(`.board > .container`).filter({ hasText: name })\n        },\n        async assertItemPosition(item: Locator, x: number, y: number) {\n            return await test.step(`Assert item position at (${x}, ${y})`, async () => {\n                const pos = await getElementPosition(item)\n                expect(pos.x).toBeGreaterThan(x - 5)\n                expect(pos.x).toBeLessThan(x + 5)\n                expect(pos.y).toBeGreaterThan(y - 5)\n                expect(pos.y).toBeLessThan(y + 5)\n            })\n        },\n        async assertItemSize(item: Locator, width: number, height: number) {\n            return await test.step(`Assert item size (${width}, ${height})`, async () => {\n                const size = await getElementSize(item)\n                expect(size.width).toBeGreaterThan(width - 5)\n                expect(size.width).toBeLessThan(width + 5)\n                expect(size.height).toBeGreaterThan(height - 5)\n                expect(size.height).toBeLessThan(height + 5)\n            })\n        },\n        async getItemPosition(item: Locator) {\n            return await getElementPosition(item)\n        },\n        async assertItemColor(item: Locator, color: string) {\n            await expect(item.locator(\".shape\")).toHaveCSS(\"background-color\", color)\n        },\n\n        async changeItemText(item: Locator, text: string) {\n            await item.click()\n            await item.locator(\".text\").dblclick()\n            await page.keyboard.type(text)\n        },\n        async assertSelected(item: Locator, selected: boolean = true) {\n            if (selected) {\n                await expect(item).toHaveClass(/selected/)\n            } else {\n                await expect(item).not.toHaveClass(/selected/)\n            }\n        },\n        async assertLocked(item: Locator, locked: boolean = true) {\n            if (locked) {\n                await expect(item).toHaveClass(/locked/)\n            } else {\n                await expect(item).not.toHaveClass(/locked/)\n            }\n        },\n        async clickOnBoard(position: { x: number; y: number }) {\n            await scrollContainer.click({ position })\n        },\n        async dragOnBoard(\n            from: { x: number; y: number },\n            to: { x: number; y: number },\n            options: { altKey?: boolean } = {},\n        ) {\n            const startDrag: DragEventInit = { ...(await itemToClientPos(from)), ...options }\n            const endDrag: DragEventInit = { ...(await itemToClientPos(to)), ...options }\n\n            await moveMouseTo(from)\n            await this.board.dispatchEvent(\"dragstart\", startDrag)\n            await this.board.dispatchEvent(\"drag\", startDrag)\n            await this.board.dispatchEvent(\"dragover\", startDrag)\n\n            await this.board.dispatchEvent(\"dragover\", endDrag)\n            await waitForThrottle()\n            await this.board.dispatchEvent(\"drag\", endDrag)\n            await this.board.dispatchEvent(\"dragend\", endDrag)\n        },\n        async selectItems(...items: Locator[]) {\n            for (let i = 0; i < items.length; i++) {\n                await this.assertLocked(items[i], false)\n                if (i === 0) {\n                    await items[i].click({ position: { x: 5, y: 5 } })\n                } else {\n                    await items[i].click({ modifiers: [\"Shift\"], position: { x: 5, y: 5 } })\n                }\n                await this.assertSelected(items[i], true)\n            }\n        },\n        async setNickname(nickname: string) {\n            await page\n                .locator(\".user-info .icon\")\n                .or(page.locator(\".user-info .nickname\"))\n                .first()\n                .click({ force: true })\n            await page.locator(\".user-info .nickname input\").fill(nickname)\n            await page.locator(\".user-info button\").click()\n        },\n        contextMenu: ContextMenu(page),\n        async deleteIndexedDb() {\n            await page.evaluate(async (boardId) => {\n                const request = indexedDB.deleteDatabase(`b/${boardId}`)\n                await new Promise((resolve, reject) => {\n                    request.onsuccess = resolve\n                    request.onerror = reject\n                })\n            }, this.getBoardId())\n            expect\n                .poll(async () => {\n                    try {\n                        const databases = await page.evaluate(async (boardId) => {\n                            return await indexedDB.databases()\n                        }, this.getBoardId())\n                        return databases\n                    } catch (e) {\n                        console.warn(\"indexedDB.databases call failed. This is not supported in Firefox\")\n                        return []\n                    }\n                })\n                .not.toContain(expect.objectContaining({ name: `b/${this.getBoardId()}` }))\n        },\n        async openBoardInNewBrowser() {\n            const boardId = this.getBoardId()\n            const newBoard = await navigateToBoard(await (await browser.newContext()).newPage(), browser, boardId)\n            return newBoard\n        },\n        async setOfflineMode(offline: boolean) {\n            await page.evaluate((offline) => {\n                ;(window as any).forceOffline.set(offline)\n            }, offline)\n            if (offline) {\n                await expect(page.locator(\".offline-status\")).toBeVisible()\n            } else {\n                await expect(page.locator(\".offline-status\")).not.toBeVisible()\n            }\n        },\n    }\n}\n\nfunction ContextMenu(page: Page) {\n    const contextMenu = page.locator(\".context-menu\")\n    return {\n        async openColorsAndShapes() {\n            await contextMenu.locator(\".colors-shapes .icon\").click()\n            return {\n                async selectColor(color: string) {\n                    await page.locator(`.submenu .colors .icon.${color}`).click()\n                },\n            }\n        },\n        async openHorizontalAlign() {\n            await contextMenu.locator(\".icon-group.align .icon\").first().click()\n            const submenu = page.locator(`.submenu.alignment.x`)\n            return {\n                left: submenu.getByTitle(\"Align left\"),\n                center: submenu.getByTitle(\"Align center\"),\n                right: submenu.getByTitle(\"Align right\"),\n            }\n        },\n        async openVerticalAlign() {\n            await contextMenu.locator(\".icon-group.align .icon\").nth(1).click()\n            const submenu = page.locator(`.submenu.alignment.y`)\n            return {\n                top: submenu.getByTitle(\"Align top\"),\n                middle: submenu.getByTitle(\"Align middle\"),\n                bottom: submenu.getByTitle(\"Align bottom\"),\n            }\n        },\n        async toggleContentsHidden() {\n            await contextMenu.locator(\".visibility .icon\").click()\n        },\n    }\n}\n"
  },
  {
    "path": "playwright/src/pages/DashboardPage.ts",
    "content": "import { Browser, Page, selectors, test } from \"@playwright/test\"\nimport { BoardPage, semiUniqueId, NewBoardOptions } from \"./BoardPage\"\n\nexport async function navigateToDashboard(page: Page, browser: Browser) {\n    selectors.setTestIdAttribute(\"data-test\")\n    await page.goto(\"http://localhost:1337\")\n    return DashboardPage(page, browser)\n}\n\nexport function DashboardPage(page: Page, browser: Browser) {\n    return {\n        async createNewBoard(options?: NewBoardOptions) {\n            return await test.step(\"Create new board\", async () => {\n                const name = options?.boardName ?? `Test board ${semiUniqueId()}`\n                const useCRDT = options?.useCRDT ?? true\n\n                await page.getByPlaceholder(\"Enter board name\").fill(name)\n                if (useCRDT) {\n                    await page.getByText(\"use collaborative text editor\").click()\n                }\n                await page.getByRole(\"button\", { name: \"Create\" }).click()\n                const board = BoardPage(page, browser)\n                // TODO: this is flaky in at least \"Switching between boards\"\n                await board.assertBoardName(name)\n                return board\n            })\n        },\n        async goToBoard(name: string) {\n            await page.locator(\".recent-boards li\").filter({ hasText: name }).first().click()\n            return BoardPage(page, browser)\n        },\n        async goToTutorialBoard() {\n            await page.getByText(\"Tutorial Board\").click()\n            return BoardPage(page, browser)\n        },\n    }\n}\n"
  },
  {
    "path": "playwright/src/tests/accessPolicy.spec.ts",
    "content": "import { Page, expect, test } from \"@playwright/test\"\nimport { sleep } from \"../../../common/src/sleep\"\nimport { navigateToBoard } from \"../pages/BoardPage\"\n\nasync function loginAsTester(page: Page) {\n    await test.step(\"Login as tester\", async () => {\n        await page.request.get(\"/test-callback\")\n    })\n}\n\nasync function logout(page: Page) {\n    await test.step(\"Logout\", async () => {\n        await page.request.get(\"/logout\")\n    })\n}\n\n// TODO: test creating accessPolicy through UI\n// TODO: test changing accessPolicy through UI\n\ntest.describe(\"Board access policy\", () => {\n    test(\"Create board with accessPolicy using API\", async ({ page, browser }) => {\n        await test.step(\"With empty accessPolicy\", async () => {\n            const board = await test.step(\"Create board and navigate\", async () => {\n                const response = await page.request.post(\"/api/v1/board\", {\n                    data: {\n                        name: \"API restricted board\",\n                        accessPolicy: {\n                            allowList: [],\n                        },\n                    },\n                })\n                const { id, accessToken } = await response.json()\n                return await navigateToBoard(page, browser, id)\n            })\n\n            await test.step(\"Verify no UI access\", async () => {\n                await board.assertStatusMessage(\"This board is for authorized users only. Click here to sign in.\")\n                await loginAsTester(page)\n                await page.reload()\n                await board.assertStatusMessage(\"Sorry, access denied. Click here to sign in with another account.\")\n                await logout(page)\n            })\n        })\n\n        await test.step(\"With non-empty accessPolicy\", async () => {\n            const board = await test.step(\"Create board and navigate\", async () => {\n                const response = await page.request.post(\"/api/v1/board\", {\n                    data: {\n                        name: \"API restricted board\",\n                        accessPolicy: {\n                            allowList: [{ email: \"ourboardtester@test.com\" }],\n                        },\n                    },\n                })\n                const { id, accessToken } = await response.json()\n                return await navigateToBoard(page, browser, id)\n            })\n\n            await test.step(\"Verify restricted access\", async () => {\n                await board.assertStatusMessage(\"This board is for authorized users only. Click here to sign in.\")\n\n                await loginAsTester(page)\n\n                await page.reload()\n                await board.assertBoardName(\"API restricted board\")\n            })\n\n            await test.step(\"Rename board through UI\", async () => {\n                await board.renameBoard(\"API restricted board renamed\")\n                await sleep(1000)\n                await page.reload()\n                await board.assertBoardName(\"API restricted board renamed\")\n            })\n\n            await test.step(\"Verify restricted access\", async () => {\n                await logout(page)\n                await page.reload()\n                await board.assertStatusMessage(\"This board is for authorized users only. Click here to sign in.\")\n            })\n        })\n    })\n\n    test(\"Create restricted board with public read access using API\", async ({ page, browser }) => {\n        const { id, accessToken } = await test.step(\"Create board and navigate\", async () => {\n            const response = await page.request.post(\"/api/v1/board\", {\n                data: {\n                    name: \"API board with public read\",\n                    accessPolicy: {\n                        publicRead: true,\n                        allowList: [{ email: \"ourboardtester@test.com\" }],\n                    },\n                    crdt: true,\n                },\n            })\n            return await response.json()\n        })\n\n        await loginAsTester(page)\n        const board = await navigateToBoard(page, browser, id)\n\n        await test.step(\"Create content as authorized user\", async () => {\n            await board.assertBoardName(\"API board with public read\")\n            await board.createNoteWithText(100, 200, \"Test note\")\n            await board.createArea(100, 400, \"Test area with CRDT\")\n        })\n\n        await test.step(\"Verify read-only access\", async () => {\n            await logout(page)\n            await page.reload()\n            await expect(board.getNote(\"Test note\")).toBeVisible()\n            await expect(board.getArea(\"Test area with CRDT\")).toBeVisible()\n\n            await board.changeItemText(board.getNote(\"Test note\"), \"Updated note\")\n            await board.changeItemText(board.getArea(\"Test area with CRDT\"), \"I should not be able to do this\")\n\n            await expect(board.getArea(\"I should not be able to do this\")).not.toBeVisible()\n        })\n\n        await test.step(\"Remove public read access\", async () => {\n            const newAccessPolicy = {\n                allowList: [{ email: \"ourboardtester@test.com\" }],\n                publicRead: false,\n            }\n            const response = await page.request.put(`/api/v1/board/${id}`, {\n                data: {\n                    name: \"Updated board name\",\n                    accessPolicy: newAccessPolicy,\n                },\n                headers: {\n                    API_TOKEN: accessToken,\n                },\n            })\n            expect(response.status()).toEqual(200)\n\n            await page.reload()\n            await board.assertStatusMessage(\"This board is for authorized users only. Click here to sign in.\")\n        })\n    })\n})\n"
  },
  {
    "path": "playwright/src/tests/api.spec.ts",
    "content": "import { Page, expect, test } from \"@playwright/test\"\nimport { Note } from \"../../../common/src/domain\"\nimport { sleep } from \"../../../common/src/sleep\"\nimport { navigateToBoard, semiUniqueId } from \"../pages/BoardPage\"\nimport { BoardApi } from \"../pages/BoardApi\"\n\ntest.describe(\"API endpoints\", () => {\n    test(\"Create and update board\", async ({ page, browser }) => {\n        const Api = BoardApi(page)\n        const { id, accessToken } = await Api.createBoard({ name: \"API test board\" })\n\n        const board = await navigateToBoard(page, browser, id)\n\n        await test.step(\"Check board name\", async () => {\n            await board.assertBoardName(\"API test board\")\n            const userPageNoteText = `note-${semiUniqueId()}`\n            await board.createNoteWithText(100, 200, userPageNoteText)\n            await board.createArea(100, 400, \"API notes\")\n            await board.createArea(550, 400, \"More API notes\")\n        })\n\n        await test.step(\"Set board name\", async () => {\n            await Api.updateBoard(accessToken, id, {\n                name: \"Updated board name\",\n            })\n            await board.assertBoardName(\"Updated board name\")\n        })\n\n        const item = await Api.createNote(accessToken, id, \"API note\", { container: \"API notes\" })\n\n        await expect(board.getNote(\"API note\")).toBeVisible()\n\n        await test.step(\"Update item\", async () => {\n            await Api.updateItem(accessToken, id, item.id, {\n                type: \"note\",\n                text: \"Updated item\",\n                color: \"#000000\",\n            })\n            await expect(board.getNote(\"Updated item\")).toBeVisible()\n        })\n\n        await test.step(\"Change item container\", async () => {\n            await board.assertItemPosition(board.getNote(\"Updated item\"), 163, 460)\n            await Api.updateItem(accessToken, id, item.id, {\n                type: \"note\",\n                text: \"Updated item\",\n                color: \"#000000\",\n                container: \"More API notes\",\n            })\n            await sleep(1000)\n            await board.assertItemPosition(board.getNote(\"Updated item\"), 613, 460)\n        })\n\n        const itemWithCoordinates = await test.step(\"Create new item with position and size\", async () => {\n            const itemWithCoordinates = await Api.createNote(accessToken, id, \"API New note\", {\n                x: 100,\n                y: 200,\n                width: 15,\n                height: 30,\n            })\n\n            const noteElement = board.getNote(\"API New note\")\n            await expect(noteElement).toBeVisible()\n            const style = await noteElement.getAttribute(\"style\")\n            expect(style).toContain(\"transform: translate(100em, 200em)\")\n            expect(style).toContain(\"width: 15em\")\n            expect(style).toContain(\"height: 30em\")\n            return itemWithCoordinates\n        })\n\n        await test.step(\"Update item position and size\", async () => {\n            await Api.updateItem(accessToken, id, itemWithCoordinates.id, {\n                x: 20,\n                y: 20,\n                type: \"note\",\n                text: \"Updated new item\",\n                color: \"#000000\",\n                width: 10,\n                height: 10,\n            })\n            const noteElement = board.getNote(\"Updated new item\")\n            await expect(noteElement).toBeVisible()\n            const style = await noteElement.getAttribute(\"style\")\n            expect(style).toContain(\"transform: translate(20em, 20em)\")\n            expect(style).toContain(\"width: 10em\")\n            expect(style).toContain(\"height: 10em\")\n        })\n\n        await test.step(\"Get board state\", async () => {\n            expect\n                .poll(async () => await Api.getBoard(accessToken, id))\n                .toEqual({\n                    board: {\n                        id,\n                        name: \"Updated board name\",\n                        width: 800,\n                        height: 600,\n                        serial: expect.anything(),\n                        connections: [],\n                        items: expect.anything(),\n                    },\n                })\n\n            const content = await Api.getBoard(accessToken, id)\n            expect(Object.values(content.board.items)).toEqual(\n                expect.arrayContaining([\n                    expect.objectContaining({\n                        type: \"container\",\n                        text: \"API notes\",\n                    }),\n                    expect.objectContaining({\n                        type: \"note\",\n                        text: \"Updated item\",\n                    }),\n                ]),\n            )\n        })\n\n        await test.step(\"Get board state hierarchy\", async () => {\n            expect\n                .poll(async () => await Api.getBoardHierarchy(accessToken, id))\n                .toEqual({\n                    board: {\n                        id,\n                        name: \"Updated board name\",\n                        width: 800,\n                        height: 600,\n                        serial: expect.anything(),\n                        connections: [],\n                        items: expect.anything(),\n                    },\n                })\n        })\n\n        await test.step(\"Get board history\", async () => {\n            const history = (await Api.getBoardHistory(accessToken, id)).history\n            expect(history.length).toBeGreaterThan(0)\n        })\n\n        await test.step(\"Get board as CSV\", async () => {\n            // CSV only includes items that are in containers\n            expect(await Api.getBoardCsv(accessToken, id)).toEqual(\"More API notes,Updated item\\n\")\n        })\n\n        await test.step(\"Set accessPolicy\", async () => {\n            await Api.updateBoard(accessToken, id, {\n                name: \"Updated board name\",\n                accessPolicy: {\n                    allowList: [],\n                    publicRead: false,\n                    publicWrite: false,\n                },\n            })\n            await page.reload()\n            await board.assertStatusMessage(\"This board is for authorized users only. Click here to sign in.\")\n\n            expect((await Api.getBoard(accessToken, id)).board.accessPolicy).toEqual({\n                allowList: [],\n                publicRead: false,\n                publicWrite: false,\n            })\n        })\n\n        await test.step(\"Update accessPolicy\", async () => {\n            const newAccessPolicy = {\n                allowList: [{ email: \"ourboardtester@test.com\" }],\n                publicRead: true,\n                publicWrite: true,\n            }\n            await Api.updateBoard(accessToken, id, {\n                name: \"Updated board name\",\n                accessPolicy: newAccessPolicy,\n            })\n            await page.reload()\n            await expect(board.getNote(\"Updated item\")).toBeVisible()\n\n            expect((await Api.getBoard(accessToken, id)).board.accessPolicy).toEqual(newAccessPolicy)\n        })\n    })\n\n    test(\"Get board contents with CRDT text\", async ({ page, browser }) => {\n        const Api = BoardApi(page)\n        const { id, accessToken } = await Api.createBoard({ name: \"API test board\", crdt: true })\n\n        const board = await navigateToBoard(page, browser, id)\n        await board.createText(100, 200, \"CRDT text\")\n        await board.createNoteWithText(100, 400, \"Simple note\")\n\n        await test.step(\"Get board state\", async () => {\n            const content = await Api.getBoard(accessToken, id)\n            expect(Object.values(content.board.items)).toEqual(\n                expect.arrayContaining([\n                    expect.objectContaining({\n                        type: \"text\",\n                        text: \"CRDT text\",\n                        textAsDelta: [{ insert: \"CRDT text\" }],\n                    }),\n                    expect.objectContaining({\n                        type: \"note\",\n                        text: \"Simple note\",\n                    }),\n                ]),\n            )\n            expect((Object.values(content.board.items)[1] as Note).textAsDelta).toBeUndefined()\n        })\n\n        await test.step(\"Get board hierarchy\", async () => {\n            const content = await Api.getBoardHierarchy(accessToken, id)\n\n            expect(Object.values(content.board.items)).toEqual(\n                expect.arrayContaining([\n                    expect.objectContaining({\n                        type: \"text\",\n                        text: \"CRDT text\",\n                        textAsDelta: [{ insert: \"CRDT text\" }],\n                    }),\n                ]),\n            )\n        })\n    })\n})\n"
  },
  {
    "path": "playwright/src/tests/board.spec.ts",
    "content": "import { Browser, Page, expect, test } from \"@playwright/test\"\nimport { sleep } from \"../../../common/src/sleep\"\nimport { BoardPage, navigateToNewBoard, semiUniqueId } from \"../pages/BoardPage\"\nimport { BoardApi } from \"../pages/BoardApi\"\n\ntest.describe(\"Basic board functionality\", () => {\n    test(\"Create note by dragging from palette\", async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser)\n        const userPageNoteText = `note-${semiUniqueId()}`\n        await board.createNoteWithText(100, 200, userPageNoteText)\n    })\n\n    testWithBothBoardTypes(\"Create text by dragging from palette\", async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser)\n        const userPageNoteText = `note-${semiUniqueId()}`\n        await board.createText(100, 200, userPageNoteText)\n    })\n\n    testWithBothBoardTypes(\"Create container by dragging from palette\", async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser)\n        const userPageNoteText = `note-${semiUniqueId()}`\n        await board.createArea(100, 200, userPageNoteText)\n    })\n\n    test(\"Create note by double clicking on board\", async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser)\n        await board.board.dblclick({ position: { x: 200, y: 200 } })\n        await expect(board.getNote(\"HELLO\")).toBeVisible()\n\n        await test.step(\"Also inside an Area\", async () => {\n            await board.createArea(300, 200, \"Container\")\n            await board.board.dblclick({ position: { x: 350, y: 250 } })\n            await expect(board.getNote(\"HELLO\")).toHaveCount(2)\n        })\n    })\n\n    testWithBothBoardTypes(\"Drag notes\", async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser)\n        const monoids = await board.createNoteWithText(100, 200, \"Monoids\")\n        const semigroups = await board.createNoteWithText(200, 200, \"Semigroups\")\n\n        await test.step(\"Drag to new position\", async () => {\n            await board.dragItem(monoids, 300, 300)\n            await board.assertItemPosition(monoids, 300, 300)\n        })\n\n        const area = await board.createArea(450, 100, \"Container\")\n        await test.step(\"Drag multiple items\", async () => {\n            await board.selectItems(monoids, semigroups)\n            await board.dragItem(monoids, 600, 300)\n            await board.assertItemPosition(monoids, 600, 300)\n            await board.assertItemPosition(semigroups, 500, 200)\n        })\n\n        await test.step(\"Drag area to move contained items\", async () => {\n            await board.dragItem(area, 300, 300)\n            await board.assertItemPosition(area, 300, 300)\n            await board.assertItemPosition(monoids, 240, 360)\n            await board.assertItemPosition(semigroups, 140, 260)\n        })\n    })\n\n    // TODO: test creating and modifying connections\n\n    test(\"Change note color\", async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser)\n        const monoids = await board.createNoteWithText(100, 200, \"Monoids\")\n        const colorsAndShapes = await board.contextMenu.openColorsAndShapes()\n        await colorsAndShapes.selectColor(\"pink\")\n        await board.assertItemColor(monoids, \"rgb(253, 196, 231)\")\n    })\n\n    test.describe(\"Duplicate items\", () => {\n        testWithBothBoardTypes(\"Duplicate text by Ctrl+D\", async ({ board }) => {\n            const monoids = await board.createText(100, 200, \"Monoids\")\n            const functors = await board.createNoteWithText(300, 200, \"Functors\")\n            await board.selectItems(monoids, functors)\n            await monoids.press(\"Control+d\")\n            await expect(monoids).toHaveCount(2)\n            await expect(functors).toHaveCount(2)\n        })\n\n        testWithBothBoardTypes(\"Duplicate a container with child items\", async ({ board }) => {\n            const container = await board.createArea(100, 200, \"Container\")\n            const text = await board.createText(150, 250, \"text\")\n            await board.selectItems(container)\n            await container.press(\"Control+d\")\n            const clonedText = board.getText(\"text\").nth(1)\n            await expect(clonedText).toHaveText(\"text\")\n        })\n\n        test(\"Duplicate deeper hierarchy\", async ({ page, browser }) => {\n            const board = await navigateToNewBoard(page, browser)\n            const container = await board.createArea(100, 200, \"Top\")\n            await board.dragSelectionBottomCorner(550, 550)\n            const container2 = await board.createArea(110, 220, \"Middle\")\n            const text = await board.createText(150, 250, \"Bottom\")\n            await board.selectItems(container)\n            await container.press(\"Control+d\")\n            const clonedText = board.getText(\"Bottom\").nth(1)\n            await expect(clonedText).toHaveText(\"Bottom\")\n        })\n    })\n\n    test.skip(\"Copy, paste and cut\", async ({ page, browser }) => {\n        // TODO: not sure how to trigger native copy, paste events\n        const board = await navigateToNewBoard(page, browser)\n        const monoids = await board.createNoteWithText(100, 200, \"Monoids\")\n        await page.keyboard.press(\"Control+c\", {})\n        await board.clickOnBoard({ x: 500, y: 300 })\n        await page.keyboard.press(\"Control+v\")\n\n        await board.changeItemText(board.getNote(\"Monoids\"), \"Monads\")\n        await expect(board.getNote(\"Monoids\")).toBeVisible()\n        await expect(board.getNote(\"Monads\")).toBeVisible()\n    })\n\n    test(\"Move items with arrow keys\", async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser)\n        const monoids = await board.createNoteWithText(100, 200, \"Monoids\")\n        const origPos = await board.getItemPosition(monoids)\n        await test.step(\"Normally\", async () => {\n            await page.keyboard.press(\"ArrowRight\")\n            expect(await board.getItemPosition(monoids)).toEqual({ x: origPos.x + 14, y: origPos.y })\n        })\n        await test.step(\"Faster with shift\", async () => {\n            await page.keyboard.press(\"Shift+ArrowLeft\")\n            expect(await board.getItemPosition(monoids)).toEqual({ x: origPos.x - 125, y: origPos.y })\n        })\n        await test.step(\"Slower with alt\", async () => {\n            await page.keyboard.press(\"Alt+ArrowDown\")\n            expect(await board.getItemPosition(monoids)).toEqual({ x: origPos.x - 125, y: origPos.y + 1 })\n        })\n    })\n\n    test(\"Delete notes\", async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser)\n        const monoids = await board.createNoteWithText(100, 200, \"Monoids\")\n\n        await test.step(\"With delete key\", async () => {\n            await page.keyboard.press(\"Delete\")\n            await expect(monoids).not.toBeVisible()\n        })\n\n        await test.step(\"With backspace key\", async () => {\n            const functors = await board.createNoteWithText(100, 200, \"Functors\")\n            await page.keyboard.press(\"Delete\")\n            await expect(functors).not.toBeVisible()\n        })\n    })\n\n    test(\"Align notes\", async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser)\n        const n1 = await board.createNoteWithText(250, 120, \"ALIGN\")\n        const n2 = await board.createNoteWithText(450, 100, \"ALL\")\n        const n3 = await board.createNoteWithText(320, 250, \"THESE\")\n        await board.createNoteWithText(300, 450, \"BUT NOT THIS\")\n        await board.selectItems(n1, n2, n3)\n        const originalCoordinates = await Promise.all([n1, n2, n3].map((n) => board.getItemPosition(n)))\n        await (await board.contextMenu.openHorizontalAlign()).left.click()\n        const newCoordinates = await Promise.all([n1, n2, n3].map((n) => board.getItemPosition(n)))\n        const expectedX = originalCoordinates[0].x\n        for (let i = 1; i < newCoordinates.length; i++) {\n            expect(newCoordinates[i].x).toEqual(expectedX)\n        }\n    })\n\n    test(\"Edit note text\", async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser)\n        const monoids = await board.createNoteWithText(100, 200, \"Monoids\")\n        const semigroups = await board.createNoteWithText(200, 200, \"Semigroups\")\n\n        await test.step(\"Change text\", async () => {\n            await board.changeItemText(board.getNote(\"Monoids\"), \"Monads\")\n            await expect(board.getNote(\"Monads\")).toBeVisible()\n        })\n\n        await test.step(\"Check persistence\", async () => {\n            await sleep(1000) // Time for persistence\n            await page.reload()\n            await expect(board.getNote(\"Monads\")).toBeVisible()\n            await expect(semigroups).toBeVisible()\n        })\n\n        await test.step(\"Check with new session\", async () => {\n            const newBoard = await board.openBoardInNewBrowser()\n            await newBoard.setNickname(\"User 2\")\n            await expect(newBoard.getNote(\"Monads\")).toBeVisible()\n        })\n    })\n\n    testWithBothBoardTypes(\"Edit area text\", async ({ board, page }) => {\n        const monoids = await board.createArea(100, 200, \"Monoids\")\n        const semigroups = await board.createArea(500, 200, \"Semigroups\")\n\n        await test.step(\"Change text\", async () => {\n            await board.changeItemText(board.getArea(\"Monoids\"), \"Monads\")\n            await expect(board.getArea(\"Monads\")).toBeVisible()\n        })\n\n        await test.step(\"Check persistence\", async () => {\n            await sleep(1000) // Time for persistence\n            await page.reload()\n            await expect(board.getArea(\"Monads\")).toBeVisible()\n            await expect(semigroups).toBeVisible()\n        })\n\n        await test.step(\"Check with new session\", async () => {\n            await board.deleteIndexedDb()\n            const newBoard = await board.openBoardInNewBrowser()\n            await newBoard.setNickname(\"User 2\")\n            await expect(newBoard.getArea(\"Monads\")).toBeVisible()\n        })\n    })\n\n    /*\n    testWithBothBoardTypes(\"Simulate typing lot of text\", async ({ board, page }) => {\n        const textArea = await board.createText(100, 200, \"Initial text\")\n        await board.selectItems(textArea)\n        await board.dragSelectionBottomCorner(600, 600)\n        const item = board.board.locator(\".text\").first()\n        await board.changeItemText(item, \"New chapter in life\\n\")\n\n        await test.step(\"Change text\", async () => {\n            for (let i = 0; i < 10; i++) {\n                await item.pressSequentially(\"New chapter in life\\n\")\n                await sleep(100)\n            }\n        })\n\n        // This is just to check stored data size - compare the legacy approach vs CRDT storage.\n        //\n        // Results:\n\n        // Legacy: \t2159b JSON,\t            1 event row, 45 events\n        // CRDT:    904b CRDT + 740b JSON,  1 event row, 2 events\n\n        // SQL query: select coalesce(sum(pg_column_size(crdt_update)), 0) as crdt_size, sum(pg_column_size(events)) as json_size, count(*) as rows, sum(last_serial - first_serial - 1) as event_count from board_event where board_id ='2bc90b97-8304-4cd6-8954-b87878db6ff3';\n    })\n    */\n\n    testWithBothBoardTypes(\"Hide area contents\", async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser)\n        const area = await board.createArea(100, 200, \"Area\")\n        const text = await board.createNoteWithText(150, 250, \"text\")\n        await board.selectItems(area)\n        await board.contextMenu.toggleContentsHidden()\n        await expect(text).not.toBeVisible()\n\n        await test.step(\"Check persistence\", async () => {\n            await sleep(1000) // Time for persistence\n            await page.reload()\n            await expect(text).not.toBeVisible()\n        })\n\n        await test.step(\"Check with new session\", async () => {\n            await board.deleteIndexedDb()\n            const newBoard = await board.openBoardInNewBrowser()\n            await newBoard.setNickname(\"User 2\")\n            await expect(newBoard.getNote(\"text\")).not.toBeVisible()\n        })\n    })\n\n    test(\"Resize notes\", async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser)\n        const monoids = await board.createNoteWithText(100, 200, \"Monoids\")\n\n        await test.step(\"Can drag to resize items\", async () => {\n            await board.selectItems(monoids)\n            await board.dragSelectionBottomCorner(550, 550)\n            await board.assertItemSize(monoids, 380, 380)\n        })\n    })\n\n    test(\"Select notes\", async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser)\n        const monoids = await board.createNoteWithText(150, 200, \"Monoids\")\n        const semigroups = await board.createNoteWithText(250, 200, \"SemiGroups\")\n\n        async function resetSelection() {\n            await board.clickOnBoard({ x: 100, y: 100 })\n            await board.assertSelected(monoids, false)\n        }\n\n        await test.step(\"Can select note by dragging on board with ALT pressed\", async () => {\n            await resetSelection()\n            await board.dragOnBoard({ x: 100, y: 100 }, { x: 300, y: 300 }, { altKey: true })\n            await board.assertSelected(monoids)\n        })\n\n        await test.step(\"Can select notes with SHIFT key\", async () => {\n            await resetSelection()\n            await board.selectItems(monoids, semigroups)\n            await board.assertSelected(monoids)\n            await board.assertSelected(semigroups)\n        })\n    })\n    testWithBothBoardTypes(\"Clone the board\", async ({ board, page }) => {\n        const semigroups = await board.createArea(500, 200, \"Semigroups\")\n        const functors = await board.createNoteWithText(200, 200, \"Functors\")\n        await sleep(1000)\n        await board.cloneBoard()\n        await board.assertBoardName(\"Clone the board copy\")\n        await expect(semigroups).toBeVisible()\n        await expect(functors).toBeVisible()\n\n        await test.step(\"Check persistence\", async () => {\n            await page.reload()\n            await expect(semigroups).toBeVisible()\n        })\n\n        await test.step(\"Check with new session\", async () => {\n            await board.deleteIndexedDb()\n            const newBoard = await board.openBoardInNewBrowser()\n            await newBoard.setNickname(\"User 2\")\n            await expect(newBoard.getArea(\"Semigroups\")).toBeVisible()\n        })\n    })\n})\n\nfunction testWithBothBoardTypes(\n    name: string,\n    runTest: (options: { board: BoardPage; page: Page; browser: Browser }) => Promise<void>,\n) {\n    test(`${name}`, async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser, { boardName: name })\n        await runTest({ board, page, browser })\n    })\n    test(`${name} (legacy board)`, async ({ page, browser }) => {\n        const board = await navigateToNewBoard(page, browser, { useCRDT: false, boardName: name })\n        await runTest({ board, page, browser })\n    })\n}\n"
  },
  {
    "path": "playwright/src/tests/collaboration.spec.ts",
    "content": "import { Browser, Page, expect, test } from \"@playwright/test\"\nimport { navigateToNewBoard, semiUniqueId } from \"../pages/BoardPage\"\nimport { sleep } from \"../../../common/src/sleep\"\n\ntest.describe(\"Two simultaneous users\", () => {\n    test(\"Two anonymous users can see each other notes\", async ({ page, browser }) => {\n        const { user1Page: userPage, user2Page } = await createBoardWithTwoUsers(page, browser)\n        // create 2 notes, one on each page\n        const userPageNoteText = `note-${semiUniqueId()}`\n        await userPage.createNoteWithText(100, 200, userPageNoteText)\n        const anotherUserPageNoteText = `another-${semiUniqueId()}`\n        await user2Page.createNoteWithText(500, 200, anotherUserPageNoteText)\n\n        await expect(user2Page.getNote(userPageNoteText)).toBeVisible()\n        await expect(userPage.getNote(anotherUserPageNoteText)).toBeVisible()\n    })\n\n    test(\"Change board name\", async ({ page, browser }) => {\n        const { user1Page: userPage, user2Page } = await createBoardWithTwoUsers(page, browser)\n        // create 2 notes, one on each page\n        await userPage.renameBoard(\"Renamed board\")\n        await user2Page.assertBoardName(\"Renamed board\")\n    })\n\n    const onTopPart = { position: { x: 9, y: 15 } } as const\n\n    test(\"Users can collaboratively edit a text area\", async ({ page, browser }) => {\n        const { user1Page, user2Page } = await createBoardWithTwoUsers(page, browser)\n        await user1Page.createArea(100, 200, \"initialText\")\n        await test.step(\"Both users edit text\", async () => {\n            await user1Page.getArea(\"initialText\").click(onTopPart)\n            await user2Page.getArea(\"initialText\").press(\"ArrowDown\")\n            await user1Page.getArea(\"initialText\").pressSequentially(\"User1Text\")\n            await user2Page.getArea(\"initialText\").dblclick(onTopPart)\n            await user2Page.getArea(\"initialText\").press(\"ArrowDown\")\n            await user2Page.getArea(\"initialText\").pressSequentially(\"User2Text\")\n            await expect(user1Page.getArea(\"User1Text\")).toBeVisible()\n            await expect(user2Page.getArea(\"User1Text\")).toBeVisible()\n            await expect(user1Page.getArea(\"User2Text\")).toBeVisible()\n            await expect(user2Page.getArea(\"User2Text\")).toBeVisible()\n        })\n        await test.step(\"User 1 duplicates text area\", async () => {\n            await user1Page.cloneButton.click()\n        })\n        await test.step(\"Text changes to new area do not affect old area\", async () => {\n            const oldArea = user1Page.getArea(\"User2Text\").first()\n            const newArea = user1Page.getArea(\"User2Text\").last()\n            await newArea.press(\"Escape\")\n            await user1Page.dragItem(newArea, 300, 300)\n            await newArea.dblclick(onTopPart)\n            await newArea.press(\"ArrowDown\")\n            await newArea.pressSequentially(\"NewText\")\n            await expect(newArea).toContainText(\"NewText\")\n            await user1Page.assertSelected(newArea, true)\n            await newArea.press(\"Escape\")\n            await newArea.press(\"Escape\")\n            await user1Page.assertSelected(newArea, false)\n            await expect(oldArea).not.toContainText(\"NewText\")\n            await expect(user2Page.getArea(\"NewText\")).toBeVisible()\n        })\n        await test.step(\"Deleting the new area does not affect the old area\", async () => {\n            const newAreaUser2 = user2Page.getArea(\"NewText\")\n            await user2Page.assertSelected(newAreaUser2, false)\n            await user2Page.selectItems(newAreaUser2)\n            await newAreaUser2.press(\"Delete\")\n            await expect(user2Page.getArea(\"NewText\")).not.toBeVisible()\n            await expect(user1Page.getArea(\"NewText\")).not.toBeVisible()\n            await expect(user2Page.getArea(\"User2Text\")).toBeVisible()\n            await expect(user1Page.getArea(\"User2Text\")).toBeVisible()\n        })\n    })\n\n    test(\"Offline changes are synced on re-connection\", async ({ page, browser }) => {\n        const { user1Page, user2Page } = await createBoardWithTwoUsers(page, browser)\n        await user1Page.createNoteWithText(100, 400, \"basetext\")\n        await sleep(1000)\n        await user1Page.setOfflineMode(true)\n        await user2Page.setOfflineMode(true)\n\n        await sleep(1000)\n        await user1Page.createArea(100, 200, \"user 1 offline text\")\n        await user2Page.createArea(500, 200, \"user 2 offline text\")\n\n        await test.step(\"Verify that changes are not synced while offline\", async () => {\n            await sleep(1000)\n            await expect(user1Page.getArea(\"user 2 offline text\")).not.toBeVisible()\n        })\n\n        await test.step(\"Reload page to verify offline persistence\", async () => {\n            await user1Page.page.reload()\n            await expect(user1Page.getArea(\"user 1 offline text\")).toBeVisible()\n        })\n\n        await test.step(\"Reconnect and verify that changes are synced\", async () => {\n            await user1Page.setOfflineMode(false)\n            await user2Page.setOfflineMode(false)\n            await sleep(1000)\n            await expect(user1Page.getArea(\"user 2 offline text\")).toBeVisible()\n            await expect(user2Page.getArea(\"user 1 offline text\")).toBeVisible()\n        })\n    })\n\n    test(\"Offline changes are synced on re-connection (first event offline)\", async ({ page, browser }) => {\n        const user1Page = await navigateToNewBoard(page, browser, { boardName: \"Collab test board\" })\n\n        await user1Page.setOfflineMode(true)\n\n        await sleep(1000)\n        await user1Page.createArea(100, 200, \"user 1 offline text\")\n\n        await test.step(\"Reload page to verify offline persistence\", async () => {\n            await user1Page.page.reload()\n            await expect(user1Page.getArea(\"user 1 offline text\")).toBeVisible()\n        })\n    })\n\n    test(\"Switching between boards\", async ({ page, browser }) => {\n        const { user1Page, user2Page } = await createBoardWithTwoUsers(page, browser)\n\n        const boardName1 = await user1Page.getBoardName()\n        const monoids = await user1Page.createText(100, 200, \"Monoids\")\n        const dashboard = await user1Page.goToDashBoard()\n        await dashboard.createNewBoard({ boardName: \"Another board\" })\n        const semigroups = await user1Page.createArea(500, 200, \"Semigroups\")\n        await user1Page.goToDashBoard()\n        await dashboard.goToBoard(boardName1)\n        await expect(monoids).toBeVisible()\n        await user2Page.createNoteWithText(100, 400, \"User 2 note\")\n        await expect(user1Page.getNote(\"User 2 note\")).toBeVisible()\n        await user2Page.createText(300, 200, \"User 2 text\")\n        await expect(user1Page.getText(\"User 2 text\")).toBeVisible()\n    })\n\n    async function createBoardWithTwoUsers(page: Page, browser: Browser) {\n        const user1Page = await navigateToNewBoard(page, browser, { boardName: \"Collab test board\" })\n        await user1Page.setNickname(\"User 1\")\n\n        const boardId = user1Page.getBoardId()\n        const user2Page = await user1Page.openBoardInNewBrowser()\n        await user2Page.setNickname(\"User 2\")\n\n        await user1Page.assertBoardName(\"Collab test board\")\n        await user2Page.assertBoardName(\"Collab test board\")\n\n        return { user1Page, user2Page, boardId }\n    }\n})\n"
  },
  {
    "path": "playwright/src/tests/dashboard.spec.ts",
    "content": "import { test } from \"@playwright/test\"\nimport { navigateToBoard } from \"../pages/BoardPage\"\nimport { navigateToDashboard } from \"../pages/DashboardPage\"\n\ntest.describe(\"Dashboard\", () => {\n    test(\"Creating a new board\", async ({ page, browser }) => {\n        const dashboard = await navigateToDashboard(page, browser)\n        const board = await dashboard.createNewBoard({ boardName: \"My new board\" })\n        await board.assertBoardName(\"My new board\")\n        await board.goToDashBoard()\n\n        await test.step(\"Do it again to make sure it works\", async () => {\n            await dashboard.createNewBoard({ boardName: \"My new board\" })\n            await board.assertBoardName(\"My new board\")\n        })\n\n        await test.step(\"Navigating to the new board by URL\", async () => {\n            const boardId = board.getBoardId()\n            await board.goToDashBoard()\n            await navigateToBoard(page, browser, boardId)\n            await board.assertBoardName(\"My new board\")\n        })\n\n        await test.step(\"Navigating to the new board from the dashboard\", async () => {\n            await board.goToDashBoard()\n            await dashboard.goToBoard(\"My new board\")\n            await board.assertBoardName(\"My new board\")\n        })\n    })\n    test(\"Personal tutorial board\", async ({ page, browser }) => {\n        const dashboard = await navigateToDashboard(page, browser)\n        const board = await dashboard.goToTutorialBoard()\n        await board.assertBoardName(\"My personal tutorial board\")\n    })\n})\n"
  },
  {
    "path": "playwright/src/tests/navigation.spec.ts",
    "content": "import { expect, test } from \"@playwright/test\"\nimport { navigateToBoard } from \"../pages/BoardPage\"\n\ntest.describe(\"Navigation\", () => {\n    test(\"Navigation to default board by URL\", async ({ page, browser }) => {\n        const board = await navigateToBoard(page, browser, \"default\")\n        await board.assertBoardName(\"Test Board\")\n        expect(board.getBoardId()).toBe(\"default\")\n    })\n\n    test(\"Navigation to non-existing board by URL\", async ({ page, browser }) => {\n        const board = await navigateToBoard(page, browser, \"non-existing-board-id\")\n        await board.assertStatusMessage(\"Board not found. A typo, maybe?\")\n    })\n})\n"
  },
  {
    "path": "playwright.config.ts",
    "content": "import { PlaywrightTestConfig, devices } from \"@playwright/test\"\n\nconst ci = process.env.CI === \"true\"\n\nconst config: PlaywrightTestConfig = {\n    testDir: \"playwright\",\n    outputDir: \"playwright/results\",\n    fullyParallel: true,\n    workers: ci ? 2 : 4,\n    forbidOnly: ci,\n    timeout: 60000, // Timeout per test file (default 30000)\n    retries: ci ? 2 : 0,\n    use: {\n        baseURL: \"http://localhost:1337\",\n        actionTimeout: 15000,\n        trace: \"retain-on-failure\",\n    },\n    reporter: ci ? \"github\" : \"line\",\n    projects: [\n        {\n            name: \"chromium\",\n            use: { ...devices[\"Desktop Chrome\"] },\n        },\n        {\n            name: \"firefox\",\n            use: { ...devices[\"Desktop Firefox\"] },\n        },\n    ],\n}\nexport default config\n"
  },
  {
    "path": "scripts/migrate_user_email.sh",
    "content": "#!/bin/bash\n\nfrom_email=$1\nto_email=$2\n\nif [ -z $to_email ]; then\n  echo \"Usage: migrate_user_email.sh <fromemail> <toemail>\"\n  exit 1\nfi\n\nif [ -z $DATABASE_URL ]; then\n  echo \"DATABASE_URL env missing\"\n  exit 1\nfi\n\necho Migrating user email from $from_email to $to_email\n\ncat << EOF | psql $DATABASE_URL\nBEGIN;\ninsert into app_user(id, email) \n\tvalues (uuid_generate_v4(), '$to_email')\n\ton conflict do nothing;\n\nupdate board_access set email='$to_email' where email='$from_email';\n\ninsert into user_board(board_id, user_id, last_opened) (\n  select board_id, (select id from app_user where email='$to_email'), last_opened\n  from user_board where user_id = (select id from app_user where email='$from_email')\n) on conflict do nothing;\n\nCOMMIT;\n\nEOF\n"
  },
  {
    "path": "scripts/run_dockerized.sh",
    "content": "docker run \\\n  -it \\\n  --init \\\n  -e SESSION_SIGNING_SECRET=NOTICE________THIS_________IS____NOT___SECURE_____USE_RANDOMIZED____STRING__INSTEAD \\\n  -e DATABASE_URL=postgres://r-board:secret@host.docker.internal:13338/r-board \\\n  --mount type=bind,source=\"$(pwd)\"/backend/localfiles,target=/usr/src/app/backend/localfiles \\\n  -p 127.0.0.1:1337:1337/tcp \\\n  raimohanska/ourboard"
  },
  {
    "path": "state-management.md",
    "content": "## Challenges\n\n-   Not your typical web form\n-   Changes to board state, user cursors and locks synced between multiple clients\n-   Changes must be immediate locally for snappy UI\n-   Drag 100 items in realtime, [100 cursors moving wildly](https://youtu.be/TRT9w5c0Rp0)\n-   Undo and Redo\n-   Offline support would be nice\n\n## Solution\n\n-   Mutations as data, i.e. events based sync\n-   Events as discriminated union in TypeScript { action: \"item.move\" ... }\n-   Reducer on all clients and server\n-   Server reducer validates actions, then broadcasts\n-   Non-shared state using local Atoms and localStorage\n\n## Using Lonna / FRP\n\n-   [`uiEvents: L.Bus<AppEvent>`](https://github.com/raimohanska/r-board/blob/master/frontend/src/store/board-store.ts#L45) for dispatching local events from the UI. The dispatch function is passed to UI components\n-   uiEvents except for Undo/Redo are enqueued to [message-queue](https://github.com/raimohanska/r-board/blob/master/frontend/src/store/message-queue.ts)\n-   [eventsReducer](https://github.com/raimohanska/r-board/blob/master/frontend/src/store/board-store.ts#L96) function handles all events that update local state\n-   persistable board item events are handled by the [boardReducer](https://github.com/raimohanska/r-board/blob/master/common/src/board-reducer.ts) \"subreducer\"\n-   using the reducer we get [`state: L.Property<BoardAppState>`](https://github.com/raimohanska/r-board/blob/master/frontend/src/store/board-store.ts#L180)\n-   from this property, different slices (board, locks, history, userId) are used around the [UI](https://github.com/raimohanska/r-board/blob/master/frontend/src/board/BoardView.tsx#L56)\n-   Read-write Atoms are created for client-local state:\n    -   [`const zoom = L.atom(1)`](https://github.com/raimohanska/r-board/blob/master/frontend/src/board/BoardView.tsx#L61)\n    -   [`focus`](https://github.com/raimohanska/r-board/blob/master/frontend/src/board/BoardView.tsx#L70) is a dependent atom, allows you to set \"select these items\" but only the actually selectable ones end up selected and the selection can be further narrowed when circumstances (locks for instance) change\n-   [Server-side connection handler](https://github.com/raimohanska/r-board/blob/master/backend/src/connection-handler.ts)\n\n## More\n\n-   Undo/redo: boardReducer returns a possible \"undo action\" that is put to undo stack\n-   Action buffering: client pushes local actions to message-queue that sends them one-by-one to server, waiting for an ack for each before ditching. Allows retry in case of connection failure, so local changes are not lost. The queue is not persisted at the moment, but could be for true offline support.\n-   Action folding: redundant actions are folded in the buffer. For instance two moves for the same item.\n-   Smart sync: server assigns a serial number to all actions and returns that in the Ack. Client maintains a Snapshot with the latest serial and stores this locally. On re-connect it requests only events occurred after the locally stored serial. No need to re-fetch the full board state on connect.\n-   Server-side state management: active boards (== has sockets) are kept in memory. Reducer run for validation before dispatching. State is stored to DB primarily as events, which are sent in 1 second bundles to PostgreSQL. When board is loaded to memory, events are replayed through the reducer to come up with the most current state. Snapshots are used for faster bootstrap to avoid looping through 1000s of events on a regular basis.\n-   Assets stored on S3\n\n## Proofing\n\n-   Playwright integration test involving client and server (actually catches bugs)\n-   Performance tester: 100 cursors (SHOW!)\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"target\": \"es2019\",\n        \"lib\": [\"es2019\", \"DOM\"],\n        \"moduleResolution\": \"node\",\n        \"strict\": true,\n        \"skipLibCheck\": true,\n        \"esModuleInterop\": true,\n        \"allowSyntheticDefaultImports\": true,\n        \"forceConsistentCasingInFileNames\": true\n    }\n}\n"
  },
  {
    "path": "vitest.config.ts",
    "content": "import { defineConfig } from \"vitest/config\"\n\nconst roots = (process.env.TEST_TARGET || \"common,frontend,backend\").split(\",\")\n\nexport default defineConfig({\n    test: {\n        include: roots.map((target) => `${target}/**/*.test.ts`),\n    },\n})\n"
  }
]