From 77689f3544cc58524dfc3431e44161a80cdb593a Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 01:34:18 +0900 Subject: [PATCH 01/17] chore(deps): add graphql-yoga stack and tsconfig for BFF type checking Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bun.lock | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++- package.json | 12 +++++++--- tsconfig.json | 34 ++++++++++------------------ 3 files changed, 82 insertions(+), 26 deletions(-) diff --git a/bun.lock b/bun.lock index 2d4db7e..be46530 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,12 @@ "workspaces": { "": { "name": "ratcap", + "dependencies": { + "@graphql-tools/schema": "^10.0.38", + "dataloader": "^2.2.3", + "graphql": "^17.0.2", + "graphql-yoga": "^5.21.2", + }, "devDependencies": { "@apidevtools/swagger-parser": "^12.1.0", "@tailwindcss/cli": "^4.2.1", @@ -23,6 +29,30 @@ "@apidevtools/swagger-parser": ["@apidevtools/swagger-parser@12.1.0", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "14.0.1", "@apidevtools/openapi-schemas": "^2.1.0", "@apidevtools/swagger-methods": "^3.0.2", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "call-me-maybe": "^1.0.2" }, "peerDependencies": { "openapi-types": ">=7" } }, "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng=="], + "@envelop/core": ["@envelop/core@5.5.1", "", { "dependencies": { "@envelop/instrumentation": "^1.0.0", "@envelop/types": "^5.2.1", "@whatwg-node/promise-helpers": "^1.2.4", "tslib": "^2.5.0" } }, "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw=="], + + "@envelop/instrumentation": ["@envelop/instrumentation@1.0.0", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.2.1", "tslib": "^2.5.0" } }, "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw=="], + + "@envelop/types": ["@envelop/types@5.2.1", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.5.0" } }, "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg=="], + + "@fastify/busboy": ["@fastify/busboy@3.2.0", "", {}, "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA=="], + + "@graphql-tools/executor": ["@graphql-tools/executor@1.5.7", "", { "dependencies": { "@graphql-tools/utils": "^11.2.2", "@graphql-typed-document-node/core": "^3.2.0", "@repeaterjs/repeater": "^3.1.0", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-UcXVClkBml+qyGEsQxfEaAkboqySVGFUd9ivn7pDc9jZSckgF6zL21cNxuRH5ZA2exneV3PTtcc0I0rDOdE0Tg=="], + + "@graphql-tools/merge": ["@graphql-tools/merge@9.2.2", "", { "dependencies": { "@graphql-tools/utils": "^11.2.2", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-DSLLAztOIQId7QE3m8Ehk5lV+0pjxNSSRDHPzlYQ9E4KJ9AoUMBprC4C+eX3v4srh05S2ujm5/veqAr5yEWFSQ=="], + + "@graphql-tools/schema": ["@graphql-tools/schema@10.0.38", "", { "dependencies": { "@graphql-tools/merge": "^9.2.2", "@graphql-tools/utils": "^11.2.2", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-Kckk2/vm+rELJ7ijvFaAn9ouWSVUTD0D4SJIvYF4rKeuQHAfCzE/jzMFonZVpTXleqJjPXLZjVbuaMorw7A5Og=="], + + "@graphql-tools/utils": ["@graphql-tools/utils@11.2.2", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw=="], + + "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + + "@graphql-yoga/logger": ["@graphql-yoga/logger@2.0.1", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-Nv0BoDGLMg9QBKy9cIswQ3/6aKaKjlTh87x3GiBg2Z4RrjyrM48DvOOK0pJh1C1At+b0mUIM67cwZcFTDLN4sA=="], + + "@graphql-yoga/subscription": ["@graphql-yoga/subscription@5.0.5", "", { "dependencies": { "@graphql-yoga/typed-event-target": "^3.0.2", "@repeaterjs/repeater": "^3.0.4", "@whatwg-node/events": "^0.1.0", "tslib": "^2.8.1" } }, "sha512-oCMWOqFs6QV96/NZRt/ZhTQvzjkGB4YohBOpKM4jH/lDT4qb7Lex/aGCxpi/JD9njw3zBBtMqxbaC22+tFHVvw=="], + + "@graphql-yoga/typed-event-target": ["@graphql-yoga/typed-event-target@3.0.2", "", { "dependencies": { "@repeaterjs/repeater": "^3.0.4", "tslib": "^2.8.1" } }, "sha512-ZpJxMqB+Qfe3rp6uszCQoag4nSw42icURnBRfFYSOmTgEeOe4rD0vYlbA8spvCu2TlCesNTlEN9BLWtQqLxabA=="], + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -71,6 +101,8 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@repeaterjs/repeater": ["@repeaterjs/repeater@3.1.0", "", {}, "sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ=="], + "@tailwindcss/cli": ["@tailwindcss/cli@4.2.1", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "enhanced-resolve": "^5.19.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.2.1" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA=="], "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], @@ -107,6 +139,18 @@ "@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], + "@whatwg-node/disposablestack": ["@whatwg-node/disposablestack@0.0.6", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.6.3" } }, "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw=="], + + "@whatwg-node/events": ["@whatwg-node/events@0.1.2", "", { "dependencies": { "tslib": "^2.6.3" } }, "sha512-ApcWxkrs1WmEMS2CaLLFUEem/49erT3sxIVjpzU5f6zmVcnijtDSrhoK2zVobOIikZJdH63jdAXOrvjf6eOUNQ=="], + + "@whatwg-node/fetch": ["@whatwg-node/fetch@0.10.13", "", { "dependencies": { "@whatwg-node/node-fetch": "^0.8.3", "urlpattern-polyfill": "^10.0.0" } }, "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q=="], + + "@whatwg-node/node-fetch": ["@whatwg-node/node-fetch@0.8.6", "", { "dependencies": { "@fastify/busboy": "^3.1.1", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.6.3" } }, "sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA=="], + + "@whatwg-node/promise-helpers": ["@whatwg-node/promise-helpers@1.3.2", "", { "dependencies": { "tslib": "^2.6.3" } }, "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA=="], + + "@whatwg-node/server": ["@whatwg-node/server@0.11.0", "", { "dependencies": { "@envelop/instrumentation": "^1.0.0", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/fetch": "^0.10.13", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.6.3" } }, "sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], @@ -135,8 +179,12 @@ "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], + "cross-inspect": ["cross-inspect@1.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "dataloader": ["dataloader@2.2.3", "", {}, "sha512-y2krtASINtPFS1rSDjacrFgn1dcUuoREVabwlOGOe4SdxenREqwjwjElAdwvbGM7kgZz9a3KVicWR7vcz8rnzA=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], @@ -169,6 +217,10 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphql": ["graphql@17.0.2", "", {}, "sha512-FRWbddMxfkjiB7z+aQDWIR+E34xo9I8c9mtK2RPv8PmMzKRvrdsreHL/Ui/TmwHJfhHChEtsFPyMHKI+xuarQQ=="], + + "graphql-yoga": ["graphql-yoga@5.21.2", "", { "dependencies": { "@envelop/core": "^5.5.1", "@envelop/instrumentation": "^1.0.0", "@graphql-tools/executor": "^1.5.0", "@graphql-tools/schema": "^10.0.11", "@graphql-tools/utils": "^10.11.0", "@graphql-yoga/logger": "^2.0.1", "@graphql-yoga/subscription": "^5.0.5", "@whatwg-node/fetch": "^0.10.6", "@whatwg-node/promise-helpers": "^1.3.2", "@whatwg-node/server": "^0.11.0", "lru-cache": "^10.0.0", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^15.2.0 || ^16.0.0" } }, "sha512-IIRF/3xtjj2D6caAWL9177hQ8tV3mWB3hve1GRnz7njPhQ3iY1jFtSp98fNGv0yV9kaPh9kKQ8JWdJZnedVmDw=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -219,7 +271,7 @@ "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], - "lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -305,6 +357,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -315,6 +369,8 @@ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -337,6 +393,10 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "graphql-yoga/@graphql-tools/utils": ["@graphql-tools/utils@10.11.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], } } diff --git a/package.json b/package.json index 7c56622..1955349 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,16 @@ "private": true, "devDependencies": { "@apidevtools/swagger-parser": "^12.1.0", - "@types/bun": "latest", "@tailwindcss/cli": "^4.2.1", - "typescript": "^5", + "@types/bun": "latest", "spago": "^1.0.3", - "tailwindcss": "^4.2.1" + "tailwindcss": "^4.2.1", + "typescript": "^5" + }, + "dependencies": { + "@graphql-tools/schema": "^10.0.38", + "dataloader": "^2.2.3", + "graphql": "^17.0.2", + "graphql-yoga": "^5.21.2" } } diff --git a/tsconfig.json b/tsconfig.json index 499f723..2b0de9b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,17 @@ { "compilerOptions": { - // Environment setup & latest features - "lib": ["ESNext"], - "target": "ESNext", - "module": "Preserve", - "moduleDetection": "force", - - // Bundler mode + "strict": true, + "module": "esnext", "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, + "target": "esnext", + "allowJs": true, + "checkJs": false, "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } + "types": ["bun"] + }, + "include": [ + "index.ts", + "bff/**/*.ts", + "scripts/**/*.ts" + ] } From 79b745f658891a459cdebf6f4baedf8a96b00ec8 Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 01:34:33 +0900 Subject: [PATCH 02/17] feat(bff): add GraphQL SDL schema for Account/Profile/Metadata Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bff/graphql.d.ts | 4 +++ bff/schema.graphql | 77 ++++++++++++++++++++++++++++++++++++++++++++++ bff/schema.ts | 8 +++++ 3 files changed, 89 insertions(+) create mode 100644 bff/graphql.d.ts create mode 100644 bff/schema.graphql create mode 100644 bff/schema.ts diff --git a/bff/graphql.d.ts b/bff/graphql.d.ts new file mode 100644 index 0000000..081ef8c --- /dev/null +++ b/bff/graphql.d.ts @@ -0,0 +1,4 @@ +declare module "*.graphql" { + const content: string; + export default content; +} diff --git a/bff/schema.graphql b/bff/schema.graphql new file mode 100644 index 0000000..94048f5 --- /dev/null +++ b/bff/schema.graphql @@ -0,0 +1,77 @@ +scalar DateTime + +type Account { + id: ID! + name: String! + isBot: Boolean! + publicKey: String! + createdAt: DateTime! + moderation: Moderation + profile: Profile + metadata: [Metadata!]! +} + +enum ModerationType { + SUSPENDED + BANNED +} + +type Moderation { + type: ModerationType! + reason: String! + suspendedAt: DateTime + expiresAt: DateTime + bannedAt: DateTime +} + +type Profile { + nanoid: ID! + accountId: ID! + displayName: String + summary: String + iconUrl: String + bannerUrl: String +} + +type Metadata { + nanoid: ID! + accountId: ID! + label: String! + content: String! +} + +type AccountConnection { + items: [Account!]! + first: String + last: String +} + +input CreateAccountInput { + name: String! + isBot: Boolean +} + +input UpdateProfileInput { + displayName: String + summary: String + iconUrl: String + bannerUrl: String +} + +input UpsertMetadataInput { + label: String! + content: String! +} + +type Query { + accounts: AccountConnection! + account(id: ID!): Account +} + +type Mutation { + createAccount(input: CreateAccountInput!): Account! + updateProfile(accountId: ID!, input: UpdateProfileInput!): Profile! + createMetadata(accountId: ID!, input: UpsertMetadataInput!): Metadata! + updateMetadata(accountId: ID!, nanoid: ID!, input: UpsertMetadataInput!): Metadata! + deleteMetadata(accountId: ID!, nanoid: ID!): Boolean! +} diff --git a/bff/schema.ts b/bff/schema.ts new file mode 100644 index 0000000..19367be --- /dev/null +++ b/bff/schema.ts @@ -0,0 +1,8 @@ +import sdl from "./schema.graphql" with { type: "text" }; +import { makeExecutableSchema } from "@graphql-tools/schema"; + +import type { GraphQLSchema } from "graphql"; + +export function makeSchema(resolvers: Parameters[0]["resolvers"]): GraphQLSchema { + return makeExecutableSchema({ typeDefs: sdl, resolvers }); +} From b5885ec8b8ec53a0ba69a8220f772b0e4d4bdb58 Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 01:34:42 +0900 Subject: [PATCH 03/17] chore(spago): bump package set to 77.7.0 and add graphql-client Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bun.lock | 11 +++ package.json | 1 + spago.lock | 262 +++++++++++++++++++++++++++++++++++++++++++++++---- spago.yaml | 3 +- 4 files changed, 256 insertions(+), 21 deletions(-) diff --git a/bun.lock b/bun.lock index be46530..4574f9d 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@apidevtools/swagger-parser": "^12.1.0", "@tailwindcss/cli": "^4.2.1", "@types/bun": "latest", + "purescript-graphql-client": "^10.1.1", "spago": "^1.0.3", "tailwindcss": "^4.2.1", "typescript": "^5", @@ -139,6 +140,8 @@ "@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], + "@urql/core": ["@urql/core@1.16.2", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.0", "wonka": "^4.0.14" }, "peerDependencies": { "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" } }, "sha512-Be2ekFcRovpLx6EKu/w7M3jJdC32Ev3eXJgXcJD7pVDgjv/RfUbYC4tGU87VrvQDJOg+t4gHFtIR6Vr9ws7mbg=="], + "@whatwg-node/disposablestack": ["@whatwg-node/disposablestack@0.0.6", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.6.3" } }, "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw=="], "@whatwg-node/events": ["@whatwg-node/events@0.1.2", "", { "dependencies": { "tslib": "^2.6.3" } }, "sha512-ApcWxkrs1WmEMS2CaLLFUEem/49erT3sxIVjpzU5f6zmVcnijtDSrhoK2zVobOIikZJdH63jdAXOrvjf6eOUNQ=="], @@ -219,6 +222,8 @@ "graphql": ["graphql@17.0.2", "", {}, "sha512-FRWbddMxfkjiB7z+aQDWIR+E34xo9I8c9mtK2RPv8PmMzKRvrdsreHL/Ui/TmwHJfhHChEtsFPyMHKI+xuarQQ=="], + "graphql-ws": ["graphql-ws@5.16.2", "", { "peerDependencies": { "graphql": ">=0.11 <=16" } }, "sha512-E1uccsZxt/96jH/OwmLPuXMACILs76pKF2i3W861LpKBCYtGIyPQGtWLuBLkND4ox1KHns70e83PS4te50nvPQ=="], + "graphql-yoga": ["graphql-yoga@5.21.2", "", { "dependencies": { "@envelop/core": "^5.5.1", "@envelop/instrumentation": "^1.0.0", "@graphql-tools/executor": "^1.5.0", "@graphql-tools/schema": "^10.0.11", "@graphql-tools/utils": "^10.11.0", "@graphql-yoga/logger": "^2.0.1", "@graphql-yoga/subscription": "^5.0.5", "@whatwg-node/fetch": "^0.10.6", "@whatwg-node/promise-helpers": "^1.3.2", "@whatwg-node/server": "^0.11.0", "lru-cache": "^10.0.0", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^15.2.0 || ^16.0.0" } }, "sha512-IIRF/3xtjj2D6caAWL9177hQ8tV3mWB3hve1GRnz7njPhQ3iY1jFtSp98fNGv0yV9kaPh9kKQ8JWdJZnedVmDw=="], "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], @@ -311,6 +316,8 @@ "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "purescript-graphql-client": ["purescript-graphql-client@10.1.1", "", { "dependencies": { "@urql/core": "^1.16.2", "graphql": "^16.5.0", "graphql-ws": "^5.9.1" } }, "sha512-RYKYwwWK1+NFOWERLFfUidZLTVXAPpnqfFLJkjmDACnFzD3J1jdt7S4i1o3kUNNcEjg5ceqEgSl4jclt3zYqBw=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "readline-sync": ["readline-sync@1.4.10", "", {}, "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw=="], @@ -373,6 +380,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "wonka": ["wonka@4.0.15", "", {}, "sha512-U0IUQHKXXn6PFo9nqsHphVCE5m3IntqZNB9Jjn7EB1lrR7YTDY3YWgFvEvwniTzXSvOH/XMzAZaIfJF/LvHYXg=="], + "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], "xhr2": ["xhr2@0.2.1", "", {}, "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw=="], @@ -398,5 +407,7 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + + "purescript-graphql-client/graphql": ["graphql@16.14.2", "", {}, "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA=="], } } diff --git a/package.json b/package.json index 1955349..7c694c1 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@apidevtools/swagger-parser": "^12.1.0", "@tailwindcss/cli": "^4.2.1", "@types/bun": "latest", + "purescript-graphql-client": "^10.1.1", "spago": "^1.0.3", "tailwindcss": "^4.2.1", "typescript": "^5" diff --git a/spago.lock b/spago.lock index d232f6a..defc9cc 100644 --- a/spago.lock +++ b/spago.lock @@ -20,6 +20,7 @@ "foldable-traversable", "foreign", "foreign-object", + "graphql-client", "http-methods", "maybe", "parallel", @@ -38,7 +39,7 @@ }, "package_set": { "address": { - "registry": "73.3.0" + "registry": "77.7.0" }, "compiler": ">=0.15.15 <0.16.0", "content": { @@ -76,7 +77,7 @@ "assert-multiple": "0.4.0", "avar": "5.0.1", "axon": "0.0.3", - "b64": "0.0.8", + "b64": "0.0.9", "barbies": "1.0.1", "barlow-lens": "0.9.0", "benchlib": "0.0.4", @@ -98,6 +99,8 @@ "chameleon-styled": "2.5.0", "chameleon-transformers": "1.0.0", "channel": "1.0.0", + "chartjs": "0.1.1", + "chartjs-halogen": "0.1.0", "checked-exceptions": "3.1.1", "choku": "1.0.2", "classless": "0.1.1", @@ -109,6 +112,7 @@ "codec-argonaut": "10.0.0", "codec-json": "2.0.0", "colors": "7.0.1", + "compile-fail": "0.4.0", "concur-core": "0.5.0", "concur-react": "0.5.0", "concurrent-queues": "3.0.0", @@ -122,6 +126,7 @@ "css-class-name-extractor": "0.0.4", "css-frameworks": "1.1.0", "csv-stream": "2.3.0", + "currency": "0.1.0", "data-mvc": "0.0.2", "datetime": "6.1.0", "datetime-parsing": "0.2.0", @@ -152,7 +157,7 @@ "elmish-testing-library": "0.3.2", "elmish-time-machine": "0.4.2", "email-validate": "7.0.0", - "encoding": "0.0.9", + "encoding": "0.0.10", "enums": "6.0.1", "env-names": "0.4.0", "environment": "2.0.0", @@ -165,6 +170,7 @@ "express": "0.9.1", "ezfetch": "1.1.1", "fahrtwind": "2.0.0", + "faker-ffi": "0.1.1", "fakerjs": "0.0.1", "fallback": "0.1.0", "fast-vect": "1.2.0", @@ -191,7 +197,7 @@ "fork": "6.0.0", "form-urlencoded": "7.0.0", "formatters": "7.0.0", - "framer-motion": "2.0.1", + "framer-motion": "3.1.0", "free": "7.1.0", "freeap": "7.0.0", "freer-free": "0.0.1", @@ -207,6 +213,7 @@ "geometria": "2.2.0", "gesso": "1.0.1", "gojs": "0.1.1", + "golden-test": "0.1.0", "golem-fetch": "0.1.0", "grain": "3.0.0", "grain-router": "3.0.0", @@ -243,17 +250,19 @@ "humdrum": "0.0.1", "hylograph-canvas": "0.1.0", "hylograph-d3-kernel": "0.1.0", - "hylograph-graph": "0.1.0", - "hylograph-layout": "0.1.0", - "hylograph-music": "0.1.0", + "hylograph-graph": "0.3.0", + "hylograph-layout": "0.3.0", + "hylograph-music": "0.4.0", "hylograph-optics": "0.1.0", - "hylograph-selection": "0.1.1", - "hylograph-simulation": "0.1.0", + "hylograph-selection": "0.5.1", + "hylograph-simulation": "0.5.0", "hylograph-simulation-core": "0.1.0", - "hylograph-simulation-halogen": "0.1.0", + "hylograph-simulation-halogen": "0.5.0", "hylograph-transitions": "0.1.0", "hylograph-wasm-kernel": "0.1.0", "hyrule": "2.3.8", + "i18next": "0.1.0", + "idb": "0.2.0", "identity": "6.0.0", "identy": "4.0.1", "indexed-db": "1.0.0", @@ -274,14 +283,16 @@ "js-date": "8.0.0", "js-fetch": "0.2.1", "js-fileio": "3.0.0", - "js-intl": "1.1.4", + "js-intl": "1.3.0", "js-iterators": "0.1.1", "js-maps": "0.1.2", "js-promise": "1.0.0", "js-promise-aff": "1.0.0", + "js-temporal": "3.0.0", "js-timers": "6.1.0", "js-uri": "3.1.0", "jsdom": "1.0.0", + "jsinc": "0.5.1", "json": "1.1.0", "json-codecs": "5.0.0", "justifill": "0.5.0", @@ -295,6 +306,7 @@ "leveldb": "1.0.1", "liminal": "1.0.1", "linalg": "6.0.0", + "linear": "0.1.0", "lists": "7.0.0", "literals": "1.0.2", "logging": "3.0.0", @@ -304,6 +316,7 @@ "maps-eager": "0.5.0", "marionette": "1.0.0", "marionette-react-basic-hooks": "0.1.1", + "markdown-it-js": "0.1.0", "marked": "0.1.0", "matrices": "5.0.1", "matryoshka": "1.0.0", @@ -311,6 +324,7 @@ "media-types": "6.0.0", "meowclient": "1.0.0", "midi": "4.0.0", + "milkdown": "0.1.0", "milkis": "9.0.0", "mimetype": "0.0.1", "minibench": "4.0.1", @@ -323,6 +337,7 @@ "monoidal": "0.16.0", "morello": "0.4.0", "mote": "3.0.0", + "motion": "4.0.0", "motsunabe": "2.0.0", "mvc": "0.0.1", "mysql": "6.0.1", @@ -332,6 +347,7 @@ "naturals": "3.0.0", "nested-functor": "0.2.1", "newtype": "5.0.0", + "next-purs-rsc": "0.1.0", "nextjs": "0.1.1", "nextui": "0.2.0", "node-buffer": "9.0.0", @@ -358,6 +374,7 @@ "nonempty": "7.0.0", "now": "6.0.0", "npm-package-json": "2.0.0", + "nqueens": "0.1.4", "nullable": "6.0.0", "numberfield": "0.2.2", "numbers": "9.0.1", @@ -407,7 +424,9 @@ "priority-queue": "0.1.2", "profunctor": "6.0.1", "profunctor-lenses": "8.0.0", + "prospero": "0.1.0", "protobuf": "4.4.0", + "ps-spa": "0.7.1", "psa-utils": "8.0.0", "psci-support": "6.0.0", "punycode": "1.0.0", @@ -429,8 +448,9 @@ "react-basic-dom": "7.0.0", "react-basic-dom-beta": "0.1.1", "react-basic-emotion": "7.1.0", - "react-basic-hooks": "9.0.0", + "react-basic-hooks": "9.1.1", "react-basic-storybook": "2.0.0", + "react-dnd-kit": "0.6.0", "react-dom": "8.0.0", "react-halo": "3.0.0", "react-icons": "1.1.5", @@ -445,6 +465,7 @@ "record-extra-srghma": "0.2.8", "record-ptional-fields": "0.1.2", "record-studio": "1.0.4", + "ref-lifted": "0.0.2", "refs": "6.0.0", "remotedata": "5.0.1", "repr": "0.5.0", @@ -469,6 +490,7 @@ "semirings": "7.0.0", "shuffle": "2.0.0", "sigil": "0.3.0", + "sigil-hats": "0.3.0", "signal": "13.0.0", "simple-emitter": "3.0.1", "simple-i18n": "2.0.1", @@ -482,7 +504,7 @@ "soundfonts": "4.1.0", "sparse-matrices": "2.0.1", "sparse-polynomials": "3.0.1", - "spec": "8.1.1", + "spec": "8.1.2", "spec-discovery": "8.4.1", "spec-mocha": "5.1.1", "spec-node": "0.0.3", @@ -496,17 +518,19 @@ "string-parsers": "8.0.0", "strings": "6.0.1", "strings-extra": "4.0.0", - "stringutils": "0.0.12", + "stringutils": "0.0.13", "structured-logging": "1.0.0", "substitute": "0.2.3", + "supabase": "0.1.0", "supply": "0.2.0", "svg-parser": "3.0.0", "systemd-journald": "0.3.0", "tagged": "4.0.2", "tailrec": "6.1.0", - "tanstack-query": "2.0.0", + "tanstack-query": "3.0.0", "tecton": "0.2.1", "tecton-halogen": "0.2.0", + "temporal": "0.1.0", "test-unit": "17.0.0", "text-formatting": "0.1.0", "thermite": "6.3.1", @@ -562,6 +586,8 @@ "versions": "7.0.0", "visx": "0.0.2", "vitest": "2.0.0", + "wasm-base": "0.1.1", + "web-chain": "1.0.1", "web-clipboard": "6.0.0", "web-cssom": "2.0.0", "web-cssom-view": "0.1.0", @@ -609,18 +635,48 @@ "webextension-polyfill": "0.1.0", "webgpu": "0.0.1", "which": "2.0.0", - "whine-core": "0.0.33", + "whine-core": "0.0.34", "xterm": "1.0.0", "yaml-next": "3.1.1", + "yoga-acp-om": "0.1.1", + "yoga-better-auth": "0.2.2", + "yoga-bun-yoga": "0.2.1", + "yoga-config": "0.2.1", + "yoga-country": "3.0.0", + "yoga-docker-compose": "0.1.1", + "yoga-dynamodb": "0.1.1", + "yoga-elasticsearch": "0.1.1", + "yoga-fastify": "0.5.1", + "yoga-fastify-om": "0.4.4", "yoga-fetch": "1.0.1", - "yoga-json": "5.1.0", - "yoga-om": "0.1.0", + "yoga-fetch-om": "0.6.3", + "yoga-format": "1.0.0", + "yoga-heroui": "2.0.2", + "yoga-http-api": "0.3.1", + "yoga-jaeger": "0.1.1", + "yoga-json": "5.2.1", + "yoga-language": "1.0.0", + "yoga-next-fastify": "0.1.1", + "yoga-om": "2.2.0", + "yoga-om-layer": "2.1.0", + "yoga-om-strom": "0.4.2", + "yoga-om-workerbees": "0.1.2", + "yoga-opentelemetry": "0.2.0", + "yoga-options": "0.1.1", + "yoga-pino": "0.1.1", "yoga-postgres": "6.0.0", - "yoga-react-dom": "1.0.1", + "yoga-react": "1.0.2", + "yoga-react-dom": "2.0.1", + "yoga-react-native": "0.2.1", + "yoga-react-om": "0.2.0", "yoga-redis": "0.1.0", + "yoga-shadcn": "1.0.5", "yoga-sql-types": "0.1.0", + "yoga-sqlite": "0.3.2", + "yoga-sse": "0.1.1", "yoga-subtlecrypto": "0.1.0", - "yoga-test-docker": "0.1.0", + "yoga-test-docker": "0.1.2", + "yoga-timezone": "1.0.0", "yoga-tree": "1.0.0", "yoga-tree-svg": "0.1.0", "yoga-tree-utils": "1.0.0", @@ -682,6 +738,18 @@ "web-xhr" ] }, + "affjax-node": { + "type": "registry", + "version": "1.0.0", + "integrity": "sha256-GWxbB7X+Fac/9q+vWi0rE1E/MIvZjYUMrcYrtaQFWn0=", + "dependencies": [ + "aff", + "affjax", + "either", + "maybe", + "prelude" + ] + }, "affjax-web": { "type": "registry", "version": "1.0.0", @@ -1064,6 +1132,81 @@ "unfoldable" ] }, + "graphql-client": { + "type": "registry", + "version": "10.1.1", + "integrity": "sha256-ixks1ECwqmTqQI8ohIW/DiSn/751RIazGvAZ4u4cUg4=", + "dependencies": [ + "aff", + "affjax", + "affjax-node", + "affjax-web", + "argonaut-codecs", + "argonaut-core", + "arrays", + "bifunctors", + "control", + "datetime", + "effect", + "either", + "enums", + "foldable-traversable", + "foreign", + "foreign-object", + "halogen-subscriptions", + "heterogeneous", + "http-methods", + "identity", + "integers", + "lists", + "maybe", + "media-types", + "newtype", + "nullable", + "ordered-collections", + "prelude", + "record", + "string-parsers", + "strings", + "transformers", + "tuples", + "typelevel-lists", + "unicode", + "unsafe-coerce", + "variant" + ] + }, + "halogen-subscriptions": { + "type": "registry", + "version": "2.0.0", + "integrity": "sha256-1eBtVZENgGtKuOY9H0iuYD3dO1CSqmOIyhYe4OhypOU=", + "dependencies": [ + "arrays", + "contravariant", + "control", + "effect", + "foldable-traversable", + "maybe", + "prelude", + "refs", + "safe-coerce", + "unsafe-reference" + ] + }, + "heterogeneous": { + "type": "registry", + "version": "0.7.0", + "integrity": "sha256-pQwcFvhWL8noktxaoD7FlLs2F2lYf3QHoyO0VOWO3I0=", + "dependencies": [ + "either", + "foldable-traversable", + "functors", + "prelude", + "record", + "tuples", + "variant" + ] + }, "http-methods": { "type": "registry", "version": "6.0.0", @@ -1397,6 +1540,24 @@ "unsafe-coerce" ] }, + "string-parsers": { + "type": "registry", + "version": "8.0.0", + "integrity": "sha256-xYexodEcW37sOLAw9R37uKTZjQFEolg3oti8E/sEry4=", + "dependencies": [ + "arrays", + "control", + "either", + "enums", + "foldable-traversable", + "lists", + "maybe", + "nonempty", + "prelude", + "strings", + "tailrec" + ] + }, "strings": { "type": "registry", "version": "6.0.1", @@ -1473,6 +1634,28 @@ "integrity": "sha256-BBqYOSnAKmayRvR5mtZoCf4SlYs2ipatD/+5nQx73aw=", "dependencies": [] }, + "typelevel-lists": { + "type": "registry", + "version": "2.1.0", + "integrity": "sha256-0MjcPZTI31JjaveIUUBkUHr8QhUHy7NWj5iXNg/wZDU=", + "dependencies": [ + "prelude", + "tuples", + "typelevel-peano", + "typelevel-prelude", + "unsafe-coerce" + ] + }, + "typelevel-peano": { + "type": "registry", + "version": "1.0.1", + "integrity": "sha256-6ojCXDUL2LY6II8x2l6Ljmtta7A8feqXg/F39CTzMzk=", + "dependencies": [ + "prelude", + "typelevel-prelude", + "unsafe-coerce" + ] + }, "typelevel-prelude": { "type": "registry", "version": "7.0.0", @@ -1494,12 +1677,35 @@ "tuples" ] }, + "unicode": { + "type": "registry", + "version": "6.0.0", + "integrity": "sha256-5KugCNyYxscmZpnMbssOQA96XimuBN/bjHpdrtd5qaY=", + "dependencies": [ + "arrays", + "enums", + "integers", + "maybe", + "partial", + "prelude", + "strings", + "unsafe-coerce" + ] + }, "unsafe-coerce": { "type": "registry", "version": "6.0.0", "integrity": "sha256-0L1QsaY20OILjfU5TV72d3U/tSjhmL9hJ32WMp857Lk=", "dependencies": [] }, + "unsafe-reference": { + "type": "registry", + "version": "5.0.0", + "integrity": "sha256-ttSJTQUnK8AK2eGOsfRxLUJEDPL6pnmExjbiOqfwC6I=", + "dependencies": [ + "prelude" + ] + }, "validation": { "type": "registry", "version": "6.0.0", @@ -1513,6 +1719,22 @@ "prelude" ] }, + "variant": { + "type": "registry", + "version": "8.0.0", + "integrity": "sha256-Ydrgl8liWEwYQOWW76PdZtg3vxKpoKgsnmFyTLgZkoU=", + "dependencies": [ + "control", + "enums", + "foldable-traversable", + "lists", + "maybe", + "partial", + "prelude", + "type-equality", + "unsafe-coerce" + ] + }, "web-dom": { "type": "registry", "version": "6.0.0", diff --git a/spago.yaml b/spago.yaml index 5777290..7346ba0 100644 --- a/spago.yaml +++ b/spago.yaml @@ -16,6 +16,7 @@ package: - foldable-traversable - foreign - foreign-object + - graphql-client - http-methods - maybe - parallel @@ -30,5 +31,5 @@ package: dependencies: [] workspace: packageSet: - registry: 73.3.0 + registry: 77.7.0 extraPackages: {} From f4f80f9384826b3c6f5a6b79ca039a5bb176924d Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 02:00:59 +0900 Subject: [PATCH 04/17] feat(bff): add EmumetClient abstraction, real implementation, session module, and auth context --- bff/context.ts | 61 ++++++++ bff/emumet/client.ts | 104 +++++++++++++ bff/emumet/real.ts | 243 +++++++++++++++++++++++++++++++ bff/session.ts | 337 +++++++++++++++++++++++++++++++++++++++++++ index.ts | 175 +++------------------- tsconfig.json | 1 + 6 files changed, 763 insertions(+), 158 deletions(-) create mode 100644 bff/context.ts create mode 100644 bff/emumet/client.ts create mode 100644 bff/emumet/real.ts create mode 100644 bff/session.ts diff --git a/bff/context.ts b/bff/context.ts new file mode 100644 index 0000000..6a35a43 --- /dev/null +++ b/bff/context.ts @@ -0,0 +1,61 @@ +// ============================================================ +// GraphQL context — SessionAdapter 経由のセッション解決 + 認証 +// アダプタと EmumetClient ファクトリは index.ts から DI される。 +// ============================================================ + +import type { SessionAdapter } from "./session.ts"; +import type { EmumetClient } from "./emumet/client.ts"; + +/** + * T5 (bff/loaders.ts) で DataLoader 群に置き換わるプレースホルダ。 + * makeLoaders() のファクトリシグネチャをここで固定し、リクエスト単位の + * loader 生成をリゾルバ側から呼べるようにする。 + */ +export type Loaders = Record; + +export type GraphQLContext = { + readonly accessToken: string | null; + readonly emumet: EmumetClient | null; + /** 非 null ならレスポンスに Set-Cookie として付与する (refresh 成功時の seal 済み / 期限切れ時の clear-cookie) */ + readonly sessionCookieHeader: string | null; + makeLoaders(): Loaders; +}; + +export type ContextDeps = { + readonly adapter: SessionAdapter<{ accessToken: string }>; + /** 認証済みリクエストでのみ呼ばれる。Real: token から Real client、Mock: プロセス共有の Mock client を返す */ + readonly createEmumetClient: (accessToken: string) => EmumetClient; +}; + +export async function buildContext(req: Request, deps: ContextDeps): Promise { + const session = await deps.adapter.getSession(req); + if (!session) return unauthenticatedContext(null); + + const outcome = await deps.adapter.refreshSessionIfNeeded(session); + switch (outcome.kind) { + case "fresh": + case "refresh-failed-active": + return authenticatedContext(deps, outcome.accessToken, null); + case "refreshed": + return authenticatedContext(deps, outcome.accessToken, outcome.sessionCookieHeader); + case "refresh-failed-expired": + return unauthenticatedContext(outcome.sessionCookieHeader); + } +} + +function authenticatedContext(deps: ContextDeps, accessToken: string, sessionCookieHeader: string | null): GraphQLContext { + const emumet = deps.createEmumetClient(accessToken); + return { + accessToken, + emumet, + sessionCookieHeader, + makeLoaders: () => { + // T5: emumet (クロージャ捕捉) から DataLoader を構築する + return {}; + }, + }; +} + +function unauthenticatedContext(sessionCookieHeader: string | null): GraphQLContext { + return { accessToken: null, emumet: null, sessionCookieHeader, makeLoaders: () => ({}) }; +} diff --git a/bff/emumet/client.ts b/bff/emumet/client.ts new file mode 100644 index 0000000..7c01960 --- /dev/null +++ b/bff/emumet/client.ts @@ -0,0 +1,104 @@ +// ============================================================ +// EmumetClient — Emumet REST API への抽象インターフェース +// DTO は bff/schema.graphql と同じ camelCase 形状。 +// Real (real.ts) と Mock (mock.ts, T6) が同一契約で実装する。 +// ============================================================ + +export type ModerationType = "SUSPENDED" | "BANNED"; + +export type Moderation = { + readonly type: ModerationType; + readonly reason: string; + readonly suspendedAt: string | null; + readonly expiresAt: string | null; + readonly bannedAt: string | null; +}; + +export type Account = { + readonly id: string; + readonly name: string; + readonly isBot: boolean; + readonly publicKey: string; + readonly createdAt: string; + readonly moderation: Moderation | null; +}; + +export type AccountConnection = { + readonly items: readonly Account[]; + readonly first: string | null; + readonly last: string | null; +}; + +export type Profile = { + readonly nanoid: string; + readonly accountId: string; + readonly displayName: string | null; + readonly summary: string | null; + readonly iconUrl: string | null; + readonly bannerUrl: string | null; +}; + +export type Metadata = { + readonly nanoid: string; + readonly accountId: string; + readonly label: string; + readonly content: string; +}; + +export type CreateAccountInput = { + readonly name: string; + readonly isBot?: boolean; +}; + +/** + * Tristate 更新フィールド: undefined → JSON フィールド省略 (変更なし) / + * null → JSON null (クリア) / 値 → 値をセット + */ +export type ProfileFields = { + readonly displayName?: string | null; + readonly summary?: string | null; + readonly iconUrl?: string | null; + readonly bannerUrl?: string | null; +}; + +export type MetadataInput = { + readonly label: string; + readonly content: string; +}; + +/** Emumet API が想定外のステータスを返した場合のエラー */ +export class EmumetApiError extends Error { + readonly status: number; + constructor(status: number, body: string) { + super(`Emumet API error: status=${status} body=${body}`); + this.name = "EmumetApiError"; + this.status = status; + } +} + +/** 対象リソースが存在しない (updateMetadata の再フェッチ欠損、deleteMetadata の 404 等) */ +export class EmumetNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "EmumetNotFoundError"; + } +} + +/** + * バッチ契約: listProfiles / listMetadata は複数 accountId を 1 リクエストで取得する + * 基本操作 (DataLoader の batchFn からそのまま呼ばれる)。getProfile は + * listProfiles([id]) の薄いラッパとして各実装が提供する。 + */ +export interface EmumetClient { + listAccounts(): Promise; + getAccount(id: string): Promise; + createAccount(input: CreateAccountInput): Promise; + listProfiles(accountIds: readonly string[]): Promise; + getProfile(accountId: string): Promise; + /** プロフィール未存在なら作成 (POST)、存在すれば更新 (PUT)。戻り値は更新後の Profile */ + upsertProfile(accountId: string, fields: ProfileFields): Promise; + listMetadata(accountIds: readonly string[]): Promise; + createMetadata(accountId: string, input: MetadataInput): Promise; + updateMetadata(accountId: string, nanoid: string, input: MetadataInput): Promise; + deleteMetadata(accountId: string, nanoid: string): Promise; +} diff --git a/bff/emumet/real.ts b/bff/emumet/real.ts new file mode 100644 index 0000000..2333637 --- /dev/null +++ b/bff/emumet/real.ts @@ -0,0 +1,243 @@ +// ============================================================ +// RealEmumetClient — Emumet REST API 実装 (openapi/emumet.json 準拠) +// ワイヤ形式は snake_case、DTO (client.ts) は camelCase。境界で相互変換する。 +// ============================================================ + +import { + EmumetApiError, + EmumetNotFoundError, + type Account, + type AccountConnection, + type CreateAccountInput, + type EmumetClient, + type Metadata, + type MetadataInput, + type Moderation, + type Profile, + type ProfileFields, +} from "./client.ts"; + +export type RealEmumetConfig = { + readonly baseUrl: string; +}; + +// --- ワイヤ型 (snake_case) --- + +type WireModeration = + | { type: "suspended"; reason: string; suspended_at: string; expires_at: string | null } + | { type: "banned"; reason: string; banned_at: string }; + +type WireAccount = { + id: string; + name: string; + is_bot: boolean; + public_key: string; + created_at: string; + moderation?: WireModeration | null; +}; + +type WireAccounts = { + items: WireAccount[]; + first: string | null; + last: string | null; +}; + +type WireProfile = { + account_id: string; + nanoid: string; + display_name: string | null; + summary: string | null; + icon_url: string | null; + banner_url: string | null; +}; + +type WireMetadata = { + account_id: string; + nanoid: string; + label: string; + content: string; +}; + +// --- snake_case → camelCase 変換 --- + +function toModeration(wire: WireModeration): Moderation { + switch (wire.type) { + case "suspended": + return { + type: "SUSPENDED", + reason: wire.reason, + suspendedAt: wire.suspended_at, + expiresAt: wire.expires_at, + bannedAt: null, + }; + case "banned": + return { + type: "BANNED", + reason: wire.reason, + suspendedAt: null, + expiresAt: null, + bannedAt: wire.banned_at, + }; + } +} + +function toAccount(wire: WireAccount): Account { + return { + id: wire.id, + name: wire.name, + isBot: wire.is_bot, + publicKey: wire.public_key, + createdAt: wire.created_at, + moderation: wire.moderation ? toModeration(wire.moderation) : null, + }; +} + +function toProfile(wire: WireProfile): Profile { + return { + nanoid: wire.nanoid, + accountId: wire.account_id, + displayName: wire.display_name, + summary: wire.summary, + iconUrl: wire.icon_url, + bannerUrl: wire.banner_url, + }; +} + +function toMetadata(wire: WireMetadata): Metadata { + return { + nanoid: wire.nanoid, + accountId: wire.account_id, + label: wire.label, + content: wire.content, + }; +} + +/** Tristate → JSON body: undefined → フィールド省略 / null → JSON null / 値 → 値 */ +function profileFieldsToWire(fields: ProfileFields): Record { + const body: Record = {}; + if (fields.displayName !== undefined) body.display_name = fields.displayName; + if (fields.summary !== undefined) body.summary = fields.summary; + if (fields.iconUrl !== undefined) body.icon_url = fields.iconUrl; + if (fields.bannerUrl !== undefined) body.banner_url = fields.bannerUrl; + return body; +} + +export function createRealEmumetClient(config: RealEmumetConfig, accessToken: string): EmumetClient { + async function request(method: string, path: string, body?: unknown): Promise { + const headers: Record = { Authorization: `Bearer ${accessToken}` }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + return fetch(`${config.baseUrl}${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } + + async function expectJson(resp: Response, okStatuses: readonly number[]): Promise { + if (!okStatuses.includes(resp.status)) { + throw new EmumetApiError(resp.status, await resp.text()); + } + return await resp.json() as T; + } + + async function expectStatus(resp: Response, okStatuses: readonly number[]): Promise { + if (!okStatuses.includes(resp.status)) { + throw new EmumetApiError(resp.status, await resp.text()); + } + } + + async function listProfiles(accountIds: readonly string[]): Promise { + if (accountIds.length === 0) return []; + const resp = await request("GET", `/profiles?account_ids=${accountIds.join(",")}`); + const wires = await expectJson(resp, [200]); + return wires.map(toProfile); + } + + async function getProfile(accountId: string): Promise { + const profiles = await listProfiles([accountId]); + return profiles[0] ?? null; + } + + async function listMetadata(accountIds: readonly string[]): Promise { + if (accountIds.length === 0) return []; + const resp = await request("GET", `/metadata?account_ids=${accountIds.join(",")}`); + const wires = await expectJson(resp, [200]); + return wires.map(toMetadata); + } + + return { + async listAccounts(): Promise { + const resp = await request("GET", "/accounts"); + if (resp.status === 404) { + // Emumet は 0 件時に 404 "No accounts found" を返す → 空 connection に正規化 + return { items: [], first: null, last: null }; + } + const wire = await expectJson(resp, [200]); + return { items: wire.items.map(toAccount), first: wire.first, last: wire.last }; + }, + + async getAccount(id: string): Promise { + const resp = await request("GET", `/accounts?ids=${encodeURIComponent(id)}`); + if (resp.status === 404) return null; + const wire = await expectJson(resp, [200]); + const account = wire.items[0]; + return account ? toAccount(account) : null; + }, + + async createAccount(input: CreateAccountInput): Promise { + const resp = await request("POST", "/accounts", { name: input.name, is_bot: input.isBot ?? false }); + const wire = await expectJson(resp, [201]); + return toAccount(wire); + }, + + listProfiles, + getProfile, + + async upsertProfile(accountId: string, fields: ProfileFields): Promise { + const existing = await getProfile(accountId); + const body = profileFieldsToWire(fields); + if (existing) { + await expectStatus(await request("PUT", `/accounts/${encodeURIComponent(accountId)}/profile`, body), [204]); + } else { + await expectStatus(await request("POST", `/accounts/${encodeURIComponent(accountId)}/profile`, body), [201]); + } + const updated = await getProfile(accountId); + if (!updated) throw new EmumetApiError(500, `profile missing after upsert: accountId=${accountId}`); + return updated; + }, + + listMetadata, + + async createMetadata(accountId: string, input: MetadataInput): Promise { + const resp = await request("POST", `/accounts/${encodeURIComponent(accountId)}/metadata`, { + label: input.label, + content: input.content, + }); + const wire = await expectJson(resp, [201]); + return toMetadata(wire); + }, + + async updateMetadata(accountId: string, nanoid: string, input: MetadataInput): Promise { + await expectStatus( + await request("PUT", `/accounts/${encodeURIComponent(accountId)}/metadata/${encodeURIComponent(nanoid)}`, { + label: input.label, + content: input.content, + }), + [204], + ); + // PUT は 204 (ボディなし) のため再フェッチして対象 nanoid を返す + const metas = await listMetadata([accountId]); + const updated = metas.find((m) => m.nanoid === nanoid); + if (!updated) throw new EmumetNotFoundError(`metadata not found after update: accountId=${accountId} nanoid=${nanoid}`); + return updated; + }, + + async deleteMetadata(accountId: string, nanoid: string): Promise { + const resp = await request("DELETE", `/accounts/${encodeURIComponent(accountId)}/metadata/${encodeURIComponent(nanoid)}`); + if (resp.status === 404) { + throw new EmumetNotFoundError(`metadata not found: accountId=${accountId} nanoid=${nanoid}`); + } + await expectStatus(resp, [204]); + }, + }; +} diff --git a/bff/session.ts b/bff/session.ts new file mode 100644 index 0000000..7e5ea11 --- /dev/null +++ b/bff/session.ts @@ -0,0 +1,337 @@ +// ============================================================ +// Session foundation (side-effect-free shared module) +// bff/ から index.ts への import は禁止 (循環依存 + Bun.serve 副作用) +// のため、env デフォルトは index.ts と同じ値をここで解決する。 +// ============================================================ +const APP_ORIGIN = process.env.APP_ORIGIN || "http://localhost:3000"; +const IS_SECURE_ORIGIN = APP_ORIGIN.startsWith("https://"); +const SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME || "ratcap_session"; +const COOKIE_SECRET_BASE64 = process.env.COOKIE_SECRET_BASE64; // 32-byte key, base64-encoded +const HYDRA_PUBLIC_URL = process.env.HYDRA_PUBLIC_URL || "http://localhost:4444"; +const HYDRA_CLIENT_ID = process.env.HYDRA_CLIENT_ID || "ratcap-bff"; +const HYDRA_CLIENT_SECRET = process.env.HYDRA_CLIENT_SECRET || "dev-secret"; +const SESSION_REFRESH_SKEW_SECONDS = Number(process.env.SESSION_REFRESH_SKEW_SECONDS) || 60; // 1 min + +// ============================================================ +// Session types +// ============================================================ + +export type AppSession = { + v: 1; + sub?: string; + email?: string; + name?: string; + accessToken: string; + refreshToken?: string; + tokenType: "Bearer"; + scope: string; + expiresAt: number; // Unix timestamp +}; + +// Mock session (simple base64 JSON — NOT encrypted) +export type MockSession = { token: string; username: string }; + +export function encodeMockCookie(data: MockSession): string { + return btoa(JSON.stringify(data)); +} +export function decodeMockCookie(value: string): MockSession | null { + try { + const parsed = JSON.parse(atob(value)); + if (typeof parsed.token === "string" && typeof parsed.username === "string") return parsed; + return null; + } catch { + return null; + } +} + +// ============================================================ +// base64url helpers +// ============================================================ + +export function base64UrlEncode(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function base64UrlDecode(str: string): Uint8Array { + const padded = str.replace(/-/g, "+").replace(/_/g, "/") + "==".slice(0, (4 - str.length % 4) % 4); + return Uint8Array.from(atob(padded), c => c.charCodeAt(0)); +} + +// ============================================================ +// Cookie header helpers +// ============================================================ + +export function getCookieValue(req: Request, name: string): string | null { + const cookieHeader = req.headers.get("cookie"); + if (!cookieHeader) return null; + const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)); + return match ? match[1]! : null; +} + +function buildCookieHeader(isSecureOrigin: boolean, name: string, value: string, opts: { maxAge?: number; path?: string } = {}): string { + const parts = [`${name}=${value}`, `Path=${opts.path || "/"}`, "HttpOnly", "SameSite=Lax"]; + if (isSecureOrigin) parts.push("Secure"); + if (opts.maxAge !== undefined) parts.push(`Max-Age=${opts.maxAge}`); + return parts.join("; "); +} + +export function setCookieHeader(name: string, value: string, opts: { maxAge?: number; path?: string } = {}): string { + return buildCookieHeader(IS_SECURE_ORIGIN, name, value, opts); +} + +export function clearCookieHeader(name: string): string { + return setCookieHeader(name, "", { maxAge: 0 }); +} + +// ============================================================ +// AES-GCM cookie encryption core (key-parametrized) +// ============================================================ + +async function importCookieKey(secretBase64: string): Promise { + const raw = Uint8Array.from(atob(secretBase64), c => c.charCodeAt(0)); + if (raw.length !== 32) throw new Error("COOKIE_SECRET_BASE64 must decode to exactly 32 bytes"); + return crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]); +} + +/** Encrypt JSON-serializable data → base64url string (iv:ciphertext) */ +async function sealWithKey(key: CryptoKey, data: T): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode(JSON.stringify(data)); + const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext)); + // Concatenate iv + ciphertext, encode as base64url + const combined = new Uint8Array(iv.length + ciphertext.length); + combined.set(iv); + combined.set(ciphertext, iv.length); + const encoded = base64UrlEncode(combined); + console.log("sealCookie: plaintext length:", plaintext.length, "combined length:", combined.length, "encoded length:", encoded.length); + return encoded; +} + +/** Decrypt base64url string → parsed JSON, or null on failure */ +async function unsealWithKey(key: CryptoKey, value: string): Promise { + try { + const combined = base64UrlDecode(value); + console.log("unsealCookie: input length:", value.length, "decoded length:", combined.length); + if (combined.length < 13) return null; // 12-byte IV + at least 1 byte + const iv = combined.slice(0, 12); + const ciphertext = combined.slice(12); + const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); + return JSON.parse(new TextDecoder().decode(plaintext)) as T; + } catch (err) { + console.error("unsealCookie failed:", err); + return null; + } +} + +// --- Env-configured encryption (used by index.ts /auth/* handlers) --- + +let _cookieKey: CryptoKey | null = null; + +async function getCookieKey(): Promise { + if (_cookieKey) return _cookieKey; + if (!COOKIE_SECRET_BASE64) throw new Error("COOKIE_SECRET_BASE64 is required in real mode"); + _cookieKey = await importCookieKey(COOKIE_SECRET_BASE64); + return _cookieKey; +} + +export async function sealCookie(data: T): Promise { + return sealWithKey(await getCookieKey(), data); +} + +export async function unsealCookie(value: string): Promise { + return unsealWithKey(await getCookieKey(), value); +} + +// ============================================================ +// Session read/write helpers (env-configured) +// ============================================================ + +// --- Mock mode --- +export function getMockSession(req: Request): MockSession | null { + const value = getCookieValue(req, SESSION_COOKIE_NAME); + return value ? decodeMockCookie(value) : null; +} + +// --- Real mode --- +export async function setRealSessionCookie(headers: Headers, session: AppSession): Promise { + const sealed = await sealCookie(session); + headers.append("Set-Cookie", setCookieHeader(SESSION_COOKIE_NAME, sealed)); +} +export async function getRealSession(req: Request): Promise { + const value = getCookieValue(req, SESSION_COOKIE_NAME); + if (!value) return null; + return unsealCookie(value); +} + +// ============================================================ +// Token refresh (Hydra-parametrized core + env-configured wrapper) +// ============================================================ + +type HydraRefreshConfig = { + publicUrl: string; + clientId: string; + clientSecret: string; +}; + +async function refreshWithHydra(hydra: HydraRefreshConfig, session: AppSession): Promise { + if (!session.refreshToken) return null; + try { + const resp = await fetch(`${hydra.publicUrl}/oauth2/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + btoa(`${hydra.clientId}:${hydra.clientSecret}`), + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: session.refreshToken, + }), + }); + if (!resp.ok) return null; + const data = await resp.json() as { + access_token: string; + refresh_token?: string; + expires_in: number; + scope: string; + token_type: string; + }; + return { + ...session, + accessToken: data.access_token, + refreshToken: data.refresh_token || session.refreshToken, + scope: data.scope, + expiresAt: Math.floor(Date.now() / 1000) + data.expires_in, + }; + } catch { + return null; + } +} + +export function refreshAccessToken(session: AppSession): Promise { + return refreshWithHydra( + { publicUrl: HYDRA_PUBLIC_URL, clientId: HYDRA_CLIENT_ID, clientSecret: HYDRA_CLIENT_SECRET }, + session, + ); +} + +function isExpiringSoon(session: AppSession, skewSeconds: number): boolean { + return session.expiresAt - Math.floor(Date.now() / 1000) < skewSeconds; +} + +export function isSessionExpiringSoon(session: AppSession): boolean { + return isExpiringSoon(session, SESSION_REFRESH_SKEW_SECONDS); +} + +// ============================================================ +// SessionAdapter — BFF GraphQL context 用のセッション解決インターフェース +// index.ts が USE_MOCK に応じていずれかを組み立てて注入する (DI)。 +// ============================================================ + +/** + * lazy refresh の結果 (index.ts proxyToEmumet / handleRealAuth /auth/session と同一の 3-way + no-op): + * - fresh: refresh 不要 → 現 token を使用、Set-Cookie なし + * - refreshed: refresh 成功 → 新 token を使用、seal 済み cookie を送信 + * - refresh-failed-active: refresh 失敗かつ未期限切れ → 現 token をそのまま使用、Set-Cookie なし + * - refresh-failed-expired: refresh 失敗かつ期限切れ → 未認証扱い、clear-cookie (Max-Age=0) を送信 + */ +export type RefreshOutcome = + | { kind: "fresh"; accessToken: string } + | { kind: "refreshed"; accessToken: string; sessionCookieHeader: string } + | { kind: "refresh-failed-active"; accessToken: string } + | { kind: "refresh-failed-expired"; sessionCookieHeader: string }; + +export interface SessionAdapter { + getSession(req: Request): Promise; + refreshSessionIfNeeded(session: S): Promise; + /** session を seal した Set-Cookie ヘッダ文字列 (値全体) を返す */ + sealSessionCookie(session: S): Promise; + /** セッション cookie 削除用の Set-Cookie ヘッダ文字列 (Max-Age=0) を返す */ + clearSessionCookie(): string; +} + +export type SessionAdapterConfig = { + cookieSecretBase64: string; + sessionCookieName: string; + isSecureOrigin: boolean; + hydraPublicUrl: string; + hydraClientId: string; + hydraClientSecret: string; + refreshSkewSeconds: number; +}; + +/** Real モード: AES-GCM の AppSession を解決し、Hydra refresh に対応する */ +export function createSessionAdapter(config: SessionAdapterConfig): SessionAdapter { + let keyPromise: Promise | null = null; + const getKey = (): Promise => { + keyPromise ??= importCookieKey(config.cookieSecretBase64); + return keyPromise; + }; + + const sealSessionCookie = async (session: AppSession): Promise => { + const sealed = await sealWithKey(await getKey(), session); + return buildCookieHeader(config.isSecureOrigin, config.sessionCookieName, sealed); + }; + const clearSessionCookie = (): string => + buildCookieHeader(config.isSecureOrigin, config.sessionCookieName, "", { maxAge: 0 }); + + return { + async getSession(req) { + const value = getCookieValue(req, config.sessionCookieName); + if (!value) return null; + return unsealWithKey(await getKey(), value); + }, + async refreshSessionIfNeeded(session) { + if (!isExpiringSoon(session, config.refreshSkewSeconds)) { + return { kind: "fresh", accessToken: session.accessToken }; + } + const refreshed = await refreshWithHydra( + { publicUrl: config.hydraPublicUrl, clientId: config.hydraClientId, clientSecret: config.hydraClientSecret }, + session, + ); + if (refreshed) { + return { + kind: "refreshed", + accessToken: refreshed.accessToken, + sessionCookieHeader: await sealSessionCookie(refreshed), + }; + } + if (session.expiresAt > Math.floor(Date.now() / 1000)) { + return { kind: "refresh-failed-active", accessToken: session.accessToken }; + } + return { kind: "refresh-failed-expired", sessionCookieHeader: clearSessionCookie() }; + }, + sealSessionCookie, + clearSessionCookie, + }; +} + +/** Mock adapter が返すセッション: MockSession.token を accessToken として露出する */ +export type MockAdapterSession = { accessToken: string; username: string }; + +/** Mock モード: base64 JSON の MockSession を解決する。期限はなく refresh は no-op */ +export function createMockSessionAdapter( + sessionCookieName: string = SESSION_COOKIE_NAME, + isSecureOrigin: boolean = IS_SECURE_ORIGIN, +): SessionAdapter { + const clearSessionCookie = (): string => + buildCookieHeader(isSecureOrigin, sessionCookieName, "", { maxAge: 0 }); + + return { + getSession(req) { + const value = getCookieValue(req, sessionCookieName); + const session = value ? decodeMockCookie(value) : null; + return Promise.resolve(session ? { accessToken: session.token, username: session.username } : null); + }, + refreshSessionIfNeeded(session) { + // mock トークンに期限はない — refresh は no-op + return Promise.resolve({ kind: "fresh", accessToken: session.accessToken }); + }, + sealSessionCookie(session) { + // インターフェース完備のため提供 (mock では refresh しないため通常は使われない) + const encoded = encodeMockCookie({ token: session.accessToken, username: session.username }); + return Promise.resolve(buildCookieHeader(isSecureOrigin, sessionCookieName, encoded)); + }, + clearSessionCookie, + }; +} diff --git a/index.ts b/index.ts index 4ee4f54..fe2e10f 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,21 @@ import { renderPage } from "./dist/server.js"; +import { + sealCookie, + unsealCookie, + base64UrlEncode, + base64UrlDecode, + getCookieValue, + setCookieHeader, + clearCookieHeader, + getMockSession, + getRealSession, + setRealSessionCookie, + refreshAccessToken, + isSessionExpiringSoon, + encodeMockCookie, + type AppSession, + type MockSession, +} from "./bff/session.ts"; // ============================================================ // Configuration @@ -31,79 +48,10 @@ const SESSION_REFRESH_SKEW_SECONDS = Number(process.env.SESSION_REFRESH_SKEW_SEC // Derived config const IS_SECURE_ORIGIN = APP_ORIGIN.startsWith("https://"); -// ============================================================ -// AES-GCM Cookie Encryption (real mode) -// ============================================================ - -let _cookieKey: CryptoKey | null = null; - -async function getCookieKey(): Promise { - if (_cookieKey) return _cookieKey; - if (!COOKIE_SECRET_BASE64) throw new Error("COOKIE_SECRET_BASE64 is required in real mode"); - const raw = Uint8Array.from(atob(COOKIE_SECRET_BASE64), c => c.charCodeAt(0)); - if (raw.length !== 32) throw new Error("COOKIE_SECRET_BASE64 must decode to exactly 32 bytes"); - _cookieKey = await crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]); - return _cookieKey; -} - -/** Encrypt JSON-serializable data → base64url string (iv:ciphertext) */ -async function sealCookie(data: T): Promise { - const key = await getCookieKey(); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const plaintext = new TextEncoder().encode(JSON.stringify(data)); - const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext)); - // Concatenate iv + ciphertext, encode as base64url - const combined = new Uint8Array(iv.length + ciphertext.length); - combined.set(iv); - combined.set(ciphertext, iv.length); - const encoded = base64UrlEncode(combined); - console.log("sealCookie: plaintext length:", plaintext.length, "combined length:", combined.length, "encoded length:", encoded.length); - return encoded; -} - -/** Decrypt base64url string → parsed JSON, or null on failure */ -async function unsealCookie(value: string): Promise { - try { - const key = await getCookieKey(); - const combined = base64UrlDecode(value); - console.log("unsealCookie: input length:", value.length, "decoded length:", combined.length); - if (combined.length < 13) return null; // 12-byte IV + at least 1 byte - const iv = combined.slice(0, 12); - const ciphertext = combined.slice(12); - const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); - return JSON.parse(new TextDecoder().decode(plaintext)) as T; - } catch (err) { - console.error("unsealCookie failed:", err); - return null; - } -} - -function base64UrlEncode(bytes: Uint8Array): string { - return btoa(String.fromCharCode(...bytes)) - .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -function base64UrlDecode(str: string): Uint8Array { - const padded = str.replace(/-/g, "+").replace(/_/g, "/") + "==".slice(0, (4 - str.length % 4) % 4); - return Uint8Array.from(atob(padded), c => c.charCodeAt(0)); -} - // ============================================================ // Cookie helpers (mock: base64 JSON, real: AES-GCM) // ============================================================ -type AppSession = { - v: 1; - sub?: string; - email?: string; - name?: string; - accessToken: string; - refreshToken?: string; - tokenType: "Bearer"; - scope: string; - expiresAt: number; // Unix timestamp -}; - type PendingOAuth = { v: 1; state: string; @@ -112,36 +60,6 @@ type PendingOAuth = { expiresAt: number; // Unix timestamp }; -// Mock session (simple base64 JSON — NOT encrypted) -type MockSession = { token: string; username: string }; - -function encodeMockCookie(data: MockSession): string { - return btoa(JSON.stringify(data)); -} -function decodeMockCookie(value: string): MockSession | null { - try { - const parsed = JSON.parse(atob(value)); - if (typeof parsed.token === "string" && typeof parsed.username === "string") return parsed; - return null; - } catch { - return null; - } -} - -function getCookieValue(req: Request, name: string): string | null { - const cookieHeader = req.headers.get("cookie"); - if (!cookieHeader) return null; - const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)); - return match ? match[1]! : null; -} - -function setCookieHeader(name: string, value: string, opts: { maxAge?: number; path?: string } = {}): string { - const parts = [`${name}=${value}`, `Path=${opts.path || "/"}`, "HttpOnly", "SameSite=Lax"]; - if (IS_SECURE_ORIGIN) parts.push("Secure"); - if (opts.maxAge !== undefined) parts.push(`Max-Age=${opts.maxAge}`); - return parts.join("; "); -} - /** Validate return_to as a safe relative path (no open redirect) */ function safeReturnTo(input: string | null): string { const raw = input || "/"; @@ -150,10 +68,6 @@ function safeReturnTo(input: string | null): string { return "/"; } -function clearCookieHeader(name: string): string { - return setCookieHeader(name, "", { maxAge: 0 }); -} - // --- Mock mode session helpers --- function setMockSessionCookie(headers: Headers, data: MockSession): void { headers.append("Set-Cookie", setCookieHeader(SESSION_COOKIE_NAME, encodeMockCookie(data))); @@ -161,21 +75,8 @@ function setMockSessionCookie(headers: Headers, data: MockSession): void { function clearMockSessionCookie(headers: Headers): void { headers.append("Set-Cookie", clearCookieHeader(SESSION_COOKIE_NAME)); } -function getMockSession(req: Request): MockSession | null { - const value = getCookieValue(req, SESSION_COOKIE_NAME); - return value ? decodeMockCookie(value) : null; -} // --- Real mode session helpers --- -async function setRealSessionCookie(headers: Headers, session: AppSession): Promise { - const sealed = await sealCookie(session); - headers.append("Set-Cookie", setCookieHeader(SESSION_COOKIE_NAME, sealed)); -} -async function getRealSession(req: Request): Promise { - const value = getCookieValue(req, SESSION_COOKIE_NAME); - if (!value) return null; - return unsealCookie(value); -} async function setOAuthCookie(headers: Headers, data: PendingOAuth): Promise { const sealed = await sealCookie(data); headers.append("Set-Cookie", setCookieHeader(OAUTH_COOKIE_NAME, sealed, { maxAge: OAUTH_STATE_TTL_SECONDS })); @@ -246,48 +147,6 @@ async function pkceChallenge(verifier: string): Promise { return base64UrlEncode(new Uint8Array(hash)); } -// ============================================================ -// Token refresh -// ============================================================ - -async function refreshAccessToken(session: AppSession): Promise { - if (!session.refreshToken) return null; - try { - const resp = await fetch(`${HYDRA_PUBLIC_URL}/oauth2/token`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "Authorization": "Basic " + btoa(`${HYDRA_CLIENT_ID}:${HYDRA_CLIENT_SECRET}`), - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: session.refreshToken, - }), - }); - if (!resp.ok) return null; - const data = await resp.json() as { - access_token: string; - refresh_token?: string; - expires_in: number; - scope: string; - token_type: string; - }; - return { - ...session, - accessToken: data.access_token, - refreshToken: data.refresh_token || session.refreshToken, - scope: data.scope, - expiresAt: Math.floor(Date.now() / 1000) + data.expires_in, - }; - } catch { - return null; - } -} - -function isSessionExpiringSoon(session: AppSession): boolean { - return session.expiresAt - Math.floor(Date.now() / 1000) < SESSION_REFRESH_SKEW_SECONDS; -} - // ============================================================ // Static files // ============================================================ diff --git a/tsconfig.json b/tsconfig.json index 2b0de9b..067aca0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "allowJs": true, "checkJs": false, "noEmit": true, + "allowImportingTsExtensions": true, "types": ["bun"] }, "include": [ From 4b9df65b7c4ecdaf10f1e4faeeed3f49c570add7 Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 02:17:11 +0900 Subject: [PATCH 05/17] feat(bff): implement resolvers with DataLoader and auth guards Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bff/context.ts | 18 ++++------ bff/loaders.ts | 63 ++++++++++++++++++++++++++++++++++ bff/resolvers.ts | 88 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 bff/loaders.ts create mode 100644 bff/resolvers.ts diff --git a/bff/context.ts b/bff/context.ts index 6a35a43..4b6a03a 100644 --- a/bff/context.ts +++ b/bff/context.ts @@ -5,13 +5,9 @@ import type { SessionAdapter } from "./session.ts"; import type { EmumetClient } from "./emumet/client.ts"; +import { emptyLoaders, makeLoaders as createLoaders, type Loaders } from "./loaders.ts"; -/** - * T5 (bff/loaders.ts) で DataLoader 群に置き換わるプレースホルダ。 - * makeLoaders() のファクトリシグネチャをここで固定し、リクエスト単位の - * loader 生成をリゾルバ側から呼べるようにする。 - */ -export type Loaders = Record; +export type { Loaders } from "./loaders.ts"; export type GraphQLContext = { readonly accessToken: string | null; @@ -45,17 +41,17 @@ export async function buildContext(req: Request, deps: ContextDeps): Promise { - // T5: emumet (クロージャ捕捉) から DataLoader を構築する - return {}; - }, + makeLoaders: () => (loaders ??= createLoaders(emumet)), }; } function unauthenticatedContext(sessionCookieHeader: string | null): GraphQLContext { - return { accessToken: null, emumet: null, sessionCookieHeader, makeLoaders: () => ({}) }; + return { accessToken: null, emumet: null, sessionCookieHeader, makeLoaders: () => emptyLoaders }; } diff --git a/bff/loaders.ts b/bff/loaders.ts new file mode 100644 index 0000000..ae5e4fa --- /dev/null +++ b/bff/loaders.ts @@ -0,0 +1,63 @@ +// ============================================================ +// DataLoader — リクエスト単位のバッチローダ +// EmumetClient のバッチ契約メソッド (listProfiles/listMetadata) を +// batchFn から 1 回だけ呼び、keys の順序に再整列して返す。 +// +// DataLoader batchFn 契約 (厳守): +// (a) 常に keys と同じ長さの配列を返す +// (b) 各結果が対応 key と同じ添字に置かれる +// (c) 個別の欠損は該当位置を null / [] とし、位置を詰めない +// (filter 等による除外は誤割当の原因となるため禁止) +// (d) バッチリクエスト自体の失敗は Promise の reject で伝播する +// 参照: https://github.com/graphql/dataloader#batch-function +// ============================================================ + +import DataLoader from "dataloader"; +import { GraphQLError } from "graphql"; +import type { EmumetClient, Metadata, Profile } from "./emumet/client.ts"; + +export type Loaders = { + /** accountId → Profile (未作成なら null) */ + readonly profile: DataLoader; + /** accountId → Metadata 配列 (0 件なら []) */ + readonly metadata: DataLoader; +}; + +/** 認証済みコンテキスト用: EmumetClient にクロージャで束縛した実ローダを構築する */ +export function makeLoaders(emumet: EmumetClient): Loaders { + return { + profile: new DataLoader(async (keys) => { + const profiles = await emumet.listProfiles(keys); + const byAccountId = new Map(profiles.map((profile) => [profile.accountId, profile])); + return keys.map((key) => byAccountId.get(key) ?? null); + }), + metadata: new DataLoader(async (keys) => { + const metadata = await emumet.listMetadata(keys); + const byAccountId = new Map(); + for (const item of metadata) { + const group = byAccountId.get(item.accountId); + if (group) { + group.push(item); + } else { + byAccountId.set(item.accountId, [item]); + } + } + return keys.map((key) => byAccountId.get(key) ?? []); + }), + }; +} + +function rejectUnauthenticated(): Promise { + return Promise.reject(new GraphQLError("Unauthenticated", { extensions: { code: "UNAUTHENTICATED" } })); +} + +/** + * 未認証コンテキスト用の不活性 Loaders (T4 の空オブジェクト返却の置き換え)。 + * リゾルバは必ず先に requireEmumet で認証を確認するため実際には到達しないが、 + * 万一 load されても素の TypeError ではなく UNAUTHENTICATED で失敗する。 + * 常に失敗するだけの不活性オブジェクトなのでリクエスト間で共有してよい。 + */ +export const emptyLoaders: Loaders = { + profile: new DataLoader(rejectUnauthenticated), + metadata: new DataLoader(rejectUnauthenticated), +}; diff --git a/bff/resolvers.ts b/bff/resolvers.ts new file mode 100644 index 0000000..ac95a6c --- /dev/null +++ b/bff/resolvers.ts @@ -0,0 +1,88 @@ +// ============================================================ +// GraphQL リゾルバ — Query / Mutation / Account フィールド +// データ取得は必ず context.emumet (EmumetClient) 経由とし、 +// リゾルバ層から直接 fetch しない。 +// エラー写像: 未認証 → UNAUTHENTICATED / EmumetNotFoundError → NOT_FOUND / 他 → INTERNAL_SERVER_ERROR +// ============================================================ + +import { GraphQLError } from "graphql"; +import type { + Account, + CreateAccountInput, + EmumetClient, + MetadataInput, + ProfileFields, +} from "./emumet/client.ts"; +import { EmumetNotFoundError } from "./emumet/client.ts"; +import type { GraphQLContext } from "./context.ts"; + +/** リゾルバ入口の認証ガード。未認証 (emumet 非注入) なら UNAUTHENTICATED を投げる */ +export function requireEmumet(context: GraphQLContext): EmumetClient { + if (!context.emumet) { + throw new GraphQLError("Unauthenticated", { extensions: { code: "UNAUTHENTICATED" } }); + } + return context.emumet; +} + +function toGraphQLError(error: unknown): GraphQLError { + if (error instanceof GraphQLError) return error; + if (error instanceof EmumetNotFoundError) { + return new GraphQLError(error.message, { extensions: { code: "NOT_FOUND" }, originalError: error }); + } + return new GraphQLError("Internal server error", { + extensions: { code: "INTERNAL_SERVER_ERROR" }, + originalError: error instanceof Error ? error : undefined, + }); +} + +/** EmumetClient 呼び出しを包み、ドメインエラーを GraphQL エラーコードに写像する */ +async function withEmumetErrors(call: () => Promise): Promise { + try { + return await call(); + } catch (error) { + throw toGraphQLError(error); + } +} + +export const resolvers = { + Query: { + accounts: (_parent: unknown, _args: unknown, context: GraphQLContext) => + withEmumetErrors(() => requireEmumet(context).listAccounts()), + account: (_parent: unknown, args: { id: string }, context: GraphQLContext) => + withEmumetErrors(() => requireEmumet(context).getAccount(args.id)), + }, + Account: { + profile: (parent: Account, _args: unknown, context: GraphQLContext) => + context.makeLoaders().profile.load(parent.id), + metadata: (parent: Account, _args: unknown, context: GraphQLContext) => + context.makeLoaders().metadata.load(parent.id), + }, + Mutation: { + createAccount: (_parent: unknown, args: { input: CreateAccountInput }, context: GraphQLContext) => + withEmumetErrors(() => requireEmumet(context).createAccount(args.input)), + updateProfile: ( + _parent: unknown, + args: { accountId: string; input: ProfileFields }, + context: GraphQLContext, + ) => withEmumetErrors(() => requireEmumet(context).upsertProfile(args.accountId, args.input)), + createMetadata: ( + _parent: unknown, + args: { accountId: string; input: MetadataInput }, + context: GraphQLContext, + ) => withEmumetErrors(() => requireEmumet(context).createMetadata(args.accountId, args.input)), + updateMetadata: ( + _parent: unknown, + args: { accountId: string; nanoid: string; input: MetadataInput }, + context: GraphQLContext, + ) => + withEmumetErrors(() => requireEmumet(context).updateMetadata(args.accountId, args.nanoid, args.input)), + deleteMetadata: async ( + _parent: unknown, + args: { accountId: string; nanoid: string }, + context: GraphQLContext, + ) => { + await withEmumetErrors(() => requireEmumet(context).deleteMetadata(args.accountId, args.nanoid)); + return true; + }, + }, +}; From 0babb4384edacfb71631c8eb06388ad7ed769405 Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 02:17:15 +0900 Subject: [PATCH 06/17] feat(bff): port stateful mock API to MockEmumetClient Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bff/emumet/mock.ts | 148 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 bff/emumet/mock.ts diff --git a/bff/emumet/mock.ts b/bff/emumet/mock.ts new file mode 100644 index 0000000..917add6 --- /dev/null +++ b/bff/emumet/mock.ts @@ -0,0 +1,148 @@ +// ============================================================ +// MockEmumetClient — index.ts handleMockApi の移植 (ステートフル) +// DTO (camelCase) をそのまま保持するインメモリストア。 +// バッチ契約: listProfiles / listMetadata は accountIds 配列を受け取る。 +// ストアは createMockEmumetClient() 呼び出しごとに独立 (id カウンタも 100 にリセット)。 +// ============================================================ + +import { + EmumetNotFoundError, + type Account, + type AccountConnection, + type CreateAccountInput, + type EmumetClient, + type Metadata, + type MetadataInput, + type Profile, + type ProfileFields, +} from "./client.ts"; + +/** Tristate 更新解決 (resolveTristateUpdate 相当): undefined → 既存維持 / null → null / 値 → 値 */ +function resolveUpdate(value: string | null | undefined, existing: string | null): string | null { + return value !== undefined ? value : existing; +} + +export function createMockEmumetClient(): EmumetClient { + let idCounter = 100; + function nextId(): string { + return String(idCounter++); + } + + const accounts: Account[] = [ + { id: "acc_01", name: "alice", isBot: false, publicKey: "ed25519:AAAA", createdAt: "2025-01-15T09:00:00Z", moderation: null }, + { id: "acc_02", name: "bob", isBot: false, publicKey: "ed25519:BBBB", createdAt: "2025-02-20T14:30:00Z", moderation: null }, + { id: "acc_03", name: "bot-news", isBot: true, publicKey: "ed25519:CCCC", createdAt: "2025-03-10T00:00:00Z", moderation: null }, + ]; + + const profiles: Profile[] = [ + { accountId: "acc_01", nanoid: "prof_01", displayName: "Alice Wonderland", summary: "Exploring the rabbit hole of federated social networks.", iconUrl: "https://api.dicebear.com/9.x/thumbs/svg?seed=alice", bannerUrl: "https://picsum.photos/seed/alice/800/200" }, + { accountId: "acc_02", nanoid: "prof_02", displayName: "Bob Builder", summary: "Can we fix it? Yes we can!", iconUrl: "https://api.dicebear.com/9.x/thumbs/svg?seed=bob", bannerUrl: null }, + { accountId: "acc_03", nanoid: "prof_03", displayName: "News Bot", summary: "Automated news aggregator.", iconUrl: "https://api.dicebear.com/9.x/thumbs/svg?seed=bot", bannerUrl: null }, + ]; + + const metadata: Metadata[] = [ + { accountId: "acc_01", nanoid: "meta_01", label: "Website", content: "https://alice.example.com" }, + { accountId: "acc_01", nanoid: "meta_02", label: "Pronouns", content: "she/her" }, + { accountId: "acc_02", nanoid: "meta_03", label: "GitHub", content: "https://github.com/bob" }, + ]; + + async function listProfiles(accountIds: readonly string[]): Promise { + return profiles.filter((p) => accountIds.includes(p.accountId)); + } + + async function getProfile(accountId: string): Promise { + const found = await listProfiles([accountId]); + return found[0] ?? null; + } + + async function listMetadata(accountIds: readonly string[]): Promise { + return metadata.filter((m) => accountIds.includes(m.accountId)); + } + + return { + async listAccounts(): Promise { + return { items: accounts, first: null, last: null }; + }, + + async getAccount(id: string): Promise { + return accounts.find((a) => a.id === id) ?? null; + }, + + async createAccount(input: CreateAccountInput): Promise { + const id = "acc_" + nextId(); + const account: Account = { + id, + name: input.name, + isBot: input.isBot ?? false, + publicKey: "ed25519:MOCK_" + id, + createdAt: new Date().toISOString(), + moderation: null, + }; + accounts.push(account); + return account; + }, + + listProfiles, + getProfile, + + async upsertProfile(accountId: string, fields: ProfileFields): Promise { + const idx = profiles.findIndex((p) => p.accountId === accountId); + if (idx < 0) { + // 未存在 → 作成 (mock POST 相当): 未指定フィールドは null + const created: Profile = { + accountId, + nanoid: "prof_" + nextId(), + displayName: fields.displayName ?? null, + summary: fields.summary ?? null, + iconUrl: fields.iconUrl ?? null, + bannerUrl: fields.bannerUrl ?? null, + }; + profiles.push(created); + return created; + } + // 存在 → 更新 (mock PUT 相当): undefined → 既存維持 / null → null / 値 → 値 + const existing = profiles[idx]; + const updated: Profile = { + accountId: existing.accountId, + nanoid: existing.nanoid, + displayName: resolveUpdate(fields.displayName, existing.displayName), + summary: resolveUpdate(fields.summary, existing.summary), + iconUrl: resolveUpdate(fields.iconUrl, existing.iconUrl), + bannerUrl: resolveUpdate(fields.bannerUrl, existing.bannerUrl), + }; + profiles[idx] = updated; + return updated; + }, + + listMetadata, + + async createMetadata(accountId: string, input: MetadataInput): Promise { + const meta: Metadata = { + accountId, + nanoid: "meta_" + nextId(), + label: input.label, + content: input.content, + }; + metadata.push(meta); + return meta; + }, + + async updateMetadata(accountId: string, nanoid: string, input: MetadataInput): Promise { + const idx = metadata.findIndex((m) => m.accountId === accountId && m.nanoid === nanoid); + if (idx < 0) { + throw new EmumetNotFoundError(`metadata not found: accountId=${accountId} nanoid=${nanoid}`); + } + const updated: Metadata = { accountId, nanoid, label: input.label, content: input.content }; + metadata[idx] = updated; + return updated; + }, + + async deleteMetadata(accountId: string, nanoid: string): Promise { + const idx = metadata.findIndex((m) => m.accountId === accountId && m.nanoid === nanoid); + if (idx < 0) { + throw new EmumetNotFoundError(`metadata not found: accountId=${accountId} nanoid=${nanoid}`); + } + metadata.splice(idx, 1); + }, + }; +} From f65a382769431ddd151e435c5e9b8718ae076105 Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 02:39:27 +0900 Subject: [PATCH 07/17] feat(bff): serve GraphQL endpoint via graphql-yoga with mock/real switch Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bff/server.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++ index.ts | 31 +++++++++++++++++++++++++++++ tsconfig.json | 1 + 3 files changed, 86 insertions(+) create mode 100644 bff/server.ts diff --git a/bff/server.ts b/bff/server.ts new file mode 100644 index 0000000..5e3cd70 --- /dev/null +++ b/bff/server.ts @@ -0,0 +1,54 @@ +// ============================================================ +// Yoga 統合 — createYogaHandler(adapter, createEmumetClient) +// schema: bff/schema.ts (SDL) + bff/resolvers.ts +// context: bff/context.ts の buildContext (SessionAdapter / EmumetClient +// ファクトリは index.ts が USE_MOCK に応じて DI する) +// セッション cookie: @whatwg-node/server-plugin-cookies / CookieStore は +// HttpOnly を再現できないため不採用。context 構築時に sessionCookieHeader +// が非 null なら WeakMap に保持し、Yoga プラグインの +// onResponse フックで既存形式の Set-Cookie 文字列をそのまま append する。 +// ============================================================ + +import { createYoga } from "graphql-yoga"; +import { makeSchema } from "./schema.ts"; +import { resolvers } from "./resolvers.ts"; +import { buildContext, type ContextDeps, type GraphQLContext } from "./context.ts"; +import type { SessionAdapter } from "./session.ts"; +import type { EmumetClient } from "./emumet/client.ts"; + +// request → context → onResponse 間の受け渡しキャリア。 +// WeakMap のためレスポンス処理後のエントリは GC に任せられる (明示 delete 不要)。 +const sessionCookieHeaders = new WeakMap(); + +export function createYogaHandler( + adapter: SessionAdapter<{ accessToken: string }>, + createEmumetClient: (accessToken: string) => EmumetClient, +): (req: Request) => Promise { + const deps: ContextDeps = { adapter, createEmumetClient }; + + const yoga = createYoga({ + schema: makeSchema(resolvers), + graphqlEndpoint: "/graphql", + context: async (initial): Promise => { + const context = await buildContext(initial.request, deps); + if (context.sessionCookieHeader !== null) { + sessionCookieHeaders.set(initial.request, context.sessionCookieHeader); + } + return context; + }, + plugins: [ + { + onResponse({ request, response }) { + const header = sessionCookieHeaders.get(request); + if (header !== undefined) { + response.headers.append("Set-Cookie", header); + } + }, + }, + ], + }); + + // Bun 1.3.1+ の型問題回避のため Bun.serve には直接渡さず、 + // index.ts の既存 fetch 内からこの関数を呼ぶ (yoga.fetch はメソッド呼出で this を保持) + return async (req) => await yoga.fetch(req); +} diff --git a/index.ts b/index.ts index fe2e10f..0a04d9b 100644 --- a/index.ts +++ b/index.ts @@ -13,9 +13,15 @@ import { refreshAccessToken, isSessionExpiringSoon, encodeMockCookie, + createSessionAdapter, + createMockSessionAdapter, type AppSession, type MockSession, } from "./bff/session.ts"; +import { createYogaHandler } from "./bff/server.ts"; +import { createMockEmumetClient } from "./bff/emumet/mock.ts"; +import { createRealEmumetClient } from "./bff/emumet/real.ts"; +import type { SessionAdapter } from "./bff/session.ts"; // ============================================================ // Configuration @@ -924,6 +930,28 @@ if (!USE_MOCK) { } } +// GraphQL BFF (bff/): SessionAdapter は USE_MOCK に応じて切替。 +// Mock: base64 MockSession を解決 + プロセス共有の MockEmumetClient (token 無視)。 +// Real: AES-GCM AppSession + Hydra refresh + token から RealEmumetClient を生成。 +const sessionAdapter: SessionAdapter<{ accessToken: string }> = USE_MOCK + ? createMockSessionAdapter() + : createSessionAdapter({ + cookieSecretBase64: COOKIE_SECRET_BASE64!, + sessionCookieName: SESSION_COOKIE_NAME, + isSecureOrigin: IS_SECURE_ORIGIN, + hydraPublicUrl: HYDRA_PUBLIC_URL, + hydraClientId: HYDRA_CLIENT_ID, + hydraClientSecret: HYDRA_CLIENT_SECRET, + refreshSkewSeconds: SESSION_REFRESH_SKEW_SECONDS, + }); +const sharedMockEmumetClient = USE_MOCK ? createMockEmumetClient() : null; +const yogaHandler = createYogaHandler( + sessionAdapter, + sharedMockEmumetClient + ? () => sharedMockEmumetClient + : (token) => createRealEmumetClient({ baseUrl: EMUMET_API_URL }, token), +); + Bun.serve({ async fetch(req) { const url = new URL(req.url); @@ -931,6 +959,9 @@ Bun.serve({ const staticResponse = serveStatic(url.pathname); if (staticResponse) return staticResponse; + // GraphQL BFF endpoint + if (url.pathname === "/graphql") return yogaHandler(req); + // Auth endpoints (BFF) if (url.pathname.startsWith("/auth/")) { const authHandler = USE_MOCK ? handleMockAuth : handleRealAuth; diff --git a/tsconfig.json b/tsconfig.json index 067aca0..93e608f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "checkJs": false, "noEmit": true, "allowImportingTsExtensions": true, + "skipLibCheck": true, "types": ["bun"] }, "include": [ From 7dad88c3b9415d06667364655b71abc6998d5ef8 Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 02:39:32 +0900 Subject: [PATCH 08/17] feat(scripts): add sync-graphql.ts and generated PureScript GraphQL types Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- scripts/purescript-graphql-client.d.ts | 40 +++++ scripts/sync-graphql.ts | 151 ++++++++++++++++++ src/Generated/Directives/Gql.purs | 28 ++++ src/Generated/Schema/Gql.purs | 123 ++++++++++++++ .../Schema/Gql/Enum/ModerationType.purs | 72 +++++++++ src/Generated/Symbols.purs | 34 ++++ 6 files changed, 448 insertions(+) create mode 100644 scripts/purescript-graphql-client.d.ts create mode 100644 scripts/sync-graphql.ts create mode 100644 src/Generated/Directives/Gql.purs create mode 100644 src/Generated/Schema/Gql.purs create mode 100644 src/Generated/Schema/Gql/Enum/ModerationType.purs create mode 100644 src/Generated/Symbols.purs diff --git a/scripts/purescript-graphql-client.d.ts b/scripts/purescript-graphql-client.d.ts new file mode 100644 index 0000000..cee43f2 --- /dev/null +++ b/scripts/purescript-graphql-client.d.ts @@ -0,0 +1,40 @@ +declare module "purescript-graphql-client" { + export interface GeneratedFile { + path: string; + code: string; + } + + export interface QualifiedTypeName { + moduleName: string; + typeName: string; + } + + export interface GenerateSchemaOptions { + url: string; + dir?: string | null; + modulePath?: string[] | null; + useNewtypesForRecords?: boolean | null; + gqlScalarsToPursTypes?: Record | null; + gqlToPursTypes?: Record | null; + enumImports?: string[] | null; + customEnumCode?: + | ((args: { name: string; values: Array<{ gql: string; transformed: string }> }) => string) + | null; + cache?: { + get: (key: string) => Promise; + set: (entry: { key: string; val: unknown }) => Promise; + } | null; + idImport?: QualifiedTypeName | null; + fieldTypeOverrides?: Record> | null; + enumValueNameTransform?: ((value: string) => string) | null; + } + + export interface GenerateSchemaResult { + schemas: GeneratedFile[]; + enums: GeneratedFile[]; + directives: GeneratedFile[]; + symbols: GeneratedFile; + } + + export function generateSchema(opts: GenerateSchemaOptions): Promise; +} diff --git a/scripts/sync-graphql.ts b/scripts/sync-graphql.ts new file mode 100644 index 0000000..c6de9e0 --- /dev/null +++ b/scripts/sync-graphql.ts @@ -0,0 +1,151 @@ +/** + * Usage: bun scripts/sync-graphql.ts [--generate-only] [--schema ] + * + * Generates PureScript GraphQL client types from the local SDL + * (bff/schema.graphql) into src/Generated/ (git-tracked, like the old + * Emumet/Types.purs workflow). + * + * The codegen tool (purescript-graphql-client) requires a live introspection + * URL, so this script: + * 1. builds a resolver-less schema from the SDL via makeExecutableSchema, + * 2. serves it on a random temporary port (Bun.serve, POST only), + * 3. runs generateSchema against that URL, + * 4. stops the server (try/finally — no orphan listeners). + * + * Flags: + * --generate-only No-op accepted for symmetry with scripts/sync-api.ts. + * This script is inherently generate-only: it always reads + * the local SDL, there is nothing to fetch. + * --schema SDL file to read (default: bff/schema.graphql). + * Used by QA to point at a broken copy without touching + * the real schema. + */ + +import { makeExecutableSchema } from "@graphql-tools/schema"; +import { graphqlSync, type ExecutionResult } from "graphql"; +import { generateSchema } from "purescript-graphql-client"; + +interface GqlRequestBody { + query: string; + variables?: Record; + operationName?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +// Upstream workaround (purescript-graphql-client 10.1.1): the schema writer +// unconditionally emits `import Data.DateTime (DateTime)`, which is a +// ScopeConflict against the local `type DateTime = ...` alias it also +// generates for any scalar named DateTime (mapped or not — verified against +// gen-schema-bundled.mjs and unmapped output). Once the alias exists the +// import is unused, so remove exactly that line; only when the alias is +// present, otherwise fail loudly instead of silently editing generated code. +async function dropConflictingDateTimeImport(path: string): Promise { + const source = await Bun.file(path).text(); + const importLine = "import Data.DateTime (DateTime)\n"; + const hasAlias = /^type DateTime =/m.test(source); + if (!source.includes(importLine)) return; + if (!hasAlias) { + throw new Error(`${path}: unexpected codegen shape (DateTime import without local alias)`); + } + await Bun.write(path, source.replace(importLine, "")); + console.error(` fixup ${path}: removed conflicting Data.DateTime import`); +} + +// graphql v17 introspection reports DIRECTIVE_DEFINITION as a @deprecated +// location (new spec draft). The bundled codegen parser predates it and +// throws "Unknown directive location". The location is irrelevant to client +// codegen, so strip it from the introspection response before serving. +function stripDirectiveDefinitionLocation(result: ExecutionResult): void { + const data = result.data; + if (!isRecord(data)) return; + const schema = data["__schema"]; + if (!isRecord(schema)) return; + const directives = schema["directives"]; + if (!Array.isArray(directives)) return; + for (const directive of directives) { + if (!isRecord(directive)) continue; + const locations = directive["locations"]; + if (!Array.isArray(locations)) continue; + directive["locations"] = locations.filter((loc) => loc !== "DIRECTIVE_DEFINITION"); + } +} + +async function main(): Promise { + const args = process.argv.slice(2); + // --generate-only: accepted but a no-op (see header). + void args.includes("--generate-only"); + + let schemaPath = "bff/schema.graphql"; + const schemaIdx = args.indexOf("--schema"); + if (schemaIdx !== -1) { + const value = args[schemaIdx + 1]; + if (!value) { + console.error("Error: --schema requires a value"); + process.exit(1); + } + schemaPath = value; + } + + console.error(`=== Step 1: Building schema from ${schemaPath} ===`); + const sdl = await Bun.file(schemaPath).text(); + const schema = makeExecutableSchema({ typeDefs: sdl }); + + console.error("=== Step 2: Starting temporary introspection server ==="); + const server = Bun.serve({ + port: 0, + fetch: async (req) => { + if (req.method !== "POST") { + return new Response("Method Not Allowed", { status: 405 }); + } + try { + const body = (await req.json()) as GqlRequestBody; + const result = graphqlSync({ + schema, + source: body.query, + variableValues: body.variables, + operationName: body.operationName, + }); + stripDirectiveDefinitionLocation(result); + return Response.json(result); + } catch (err) { + return Response.json( + { errors: [{ message: err instanceof Error ? err.message : String(err) }] }, + { status: 400 }, + ); + } + }, + }); + console.error(`Temporary server listening on port ${server.port}`); + + try { + console.error("=== Step 3: Generating PureScript types ==="); + const result = await generateSchema({ + dir: "./src/Generated", + modulePath: ["Generated", "Gql"], + url: `http://localhost:${server.port}/graphql`, + // The bundled 10.1.1 codegen reads `gqlToPursTypes` (verified against + // gen-schema-bundled.mjs optsJs keys). The README-documented + // `gqlScalarsToPursTypes` key is silently ignored by this version — + // passing it leaves DateTime mapped to Data.DateTime.DateTime. + gqlToPursTypes: { DateTime: "String" }, + }); + const written = [...result.schemas, ...result.enums, ...result.directives, result.symbols]; + for (const { path } of written) { + console.error(` wrote ${path}`); + } + await dropConflictingDateTimeImport("./src/Generated/Schema/Gql.purs"); + } finally { + server.stop(true); + console.error("=== Step 4: Temporary server stopped ==="); + } + + console.error("Done. Generated files are in src/Generated/"); +} + +main().catch((err) => { + console.error("Error:", err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/src/Generated/Directives/Gql.purs b/src/Generated/Directives/Gql.purs new file mode 100644 index 0000000..a44c56b --- /dev/null +++ b/src/Generated/Directives/Gql.purs @@ -0,0 +1,28 @@ +module Generated.Directives.Gql where + +import Prelude +import GraphQL.Client.Args (NotNull) +import GraphQL.Client.Directive (ApplyDirective, applyDir) +import GraphQL.Client.Directive.Definition (Directive) +import GraphQL.Client.Directive.Location (MUTATION, QUERY, SUBSCRIPTION) +import GraphQL.Client.Operation (OpMutation(..), OpQuery(..), OpSubscription(..)) +import Type.Data.List (type (:>), List', Nil') +import Type.Proxy (Proxy(..)) + +type Directives :: List' Type +type Directives = Nil' + +include :: forall q args. args -> q -> ApplyDirective "include" args q +include = applyDir (Proxy :: _ "include") + +skip :: forall q args. args -> q -> ApplyDirective "skip" args q +skip = applyDir (Proxy :: _ "skip") + +deprecated :: forall q args. args -> q -> ApplyDirective "deprecated" args q +deprecated = applyDir (Proxy :: _ "deprecated") + +specifiedBy :: forall q args. args -> q -> ApplyDirective "specifiedBy" args q +specifiedBy = applyDir (Proxy :: _ "specifiedBy") + +oneOf :: forall q args. args -> q -> ApplyDirective "oneOf" args q +oneOf = applyDir (Proxy :: _ "oneOf") diff --git a/src/Generated/Schema/Gql.purs b/src/Generated/Schema/Gql.purs new file mode 100644 index 0000000..d620fa0 --- /dev/null +++ b/src/Generated/Schema/Gql.purs @@ -0,0 +1,123 @@ +module Generated.Schema.Gql where + +import Data.Argonaut.Core as Data.Argonaut.Core +import Data.Maybe as Data.Maybe +import Data.Newtype as Data.Newtype +import Data.Void as Data.Void +import Generated.Directives.Gql (Directives) +import Generated.Schema.Gql.Enum.ModerationType (ModerationType) +import GraphQL.Client.Args (NotNull) +import GraphQL.Client.AsGql (AsGql) +import GraphQL.Client.ID as GraphQL.Client.ID +import GraphQL.Client.Union as GraphQL.Client.Union +import Type.Proxy as Type.Proxy + +type Schema = + { directives :: Type.Proxy.Proxy Directives + , query :: Query + , mutation :: Mutation + , subscription :: Data.Void.Void + } + +type DateTime = String +type Id = + -- | The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID. + GraphQL.Client.ID.ID + +newtype Query = Query + { accounts :: {} -> AsGql "AccountConnection" AccountConnection + , account :: + { id :: NotNull (AsGql "ID" GraphQL.Client.ID.ID) } + -> AsGql "Account" (Data.Maybe.Maybe (AsGql "Account" Account)) + } + +derive instance Data.Newtype.Newtype Query _ +newtype Account = Account + { createdAt :: {} -> AsGql "DateTime" DateTime + , id :: {} -> AsGql "ID" GraphQL.Client.ID.ID + , isBot :: {} -> AsGql "Boolean" Boolean + , metadata :: {} -> Array (AsGql "Metadata" Metadata) + , moderation :: {} -> AsGql "Moderation" (Data.Maybe.Maybe (AsGql "Moderation" Moderation)) + , name :: {} -> AsGql "String" String + , profile :: {} -> AsGql "Profile" (Data.Maybe.Maybe (AsGql "Profile" Profile)) + , publicKey :: {} -> AsGql "String" String + } + +derive instance Data.Newtype.Newtype Account _ +newtype AccountConnection = AccountConnection + { first :: {} -> AsGql "String" (Data.Maybe.Maybe (AsGql "String" String)) + , items :: {} -> Array (AsGql "Account" Account) + , last :: {} -> AsGql "String" (Data.Maybe.Maybe (AsGql "String" String)) + } + +derive instance Data.Newtype.Newtype AccountConnection _ +newtype Profile = Profile + { accountId :: {} -> AsGql "ID" GraphQL.Client.ID.ID + , bannerUrl :: {} -> AsGql "String" (Data.Maybe.Maybe (AsGql "String" String)) + , displayName :: {} -> AsGql "String" (Data.Maybe.Maybe (AsGql "String" String)) + , iconUrl :: {} -> AsGql "String" (Data.Maybe.Maybe (AsGql "String" String)) + , nanoid :: {} -> AsGql "ID" GraphQL.Client.ID.ID + , summary :: {} -> AsGql "String" (Data.Maybe.Maybe (AsGql "String" String)) + } + +derive instance Data.Newtype.Newtype Profile _ +newtype Metadata = Metadata + { accountId :: {} -> AsGql "ID" GraphQL.Client.ID.ID + , content :: {} -> AsGql "String" String + , label :: {} -> AsGql "String" String + , nanoid :: {} -> AsGql "ID" GraphQL.Client.ID.ID + } + +derive instance Data.Newtype.Newtype Metadata _ +newtype Moderation = Moderation + { bannedAt :: {} -> AsGql "DateTime" (Data.Maybe.Maybe (AsGql "DateTime" DateTime)) + , expiresAt :: {} -> AsGql "DateTime" (Data.Maybe.Maybe (AsGql "DateTime" DateTime)) + , reason :: {} -> AsGql "String" String + , suspendedAt :: {} -> AsGql "DateTime" (Data.Maybe.Maybe (AsGql "DateTime" DateTime)) + , type :: {} -> AsGql "ModerationType" ModerationType + } + +derive instance Data.Newtype.Newtype Moderation _ +newtype Mutation = Mutation + { updateProfile :: + { accountId :: NotNull (AsGql "ID" GraphQL.Client.ID.ID) + , input :: NotNull (AsGql "UpdateProfileInput" UpdateProfileInput) + } + -> AsGql "Profile" Profile + , createMetadata :: + { accountId :: NotNull (AsGql "ID" GraphQL.Client.ID.ID) + , input :: NotNull (AsGql "UpsertMetadataInput" UpsertMetadataInput) + } + -> AsGql "Metadata" Metadata + , deleteMetadata :: + { accountId :: NotNull (AsGql "ID" GraphQL.Client.ID.ID) + , nanoid :: NotNull (AsGql "ID" GraphQL.Client.ID.ID) + } + -> AsGql "Boolean" Boolean + , updateMetadata :: + { accountId :: NotNull (AsGql "ID" GraphQL.Client.ID.ID) + , input :: NotNull (AsGql "UpsertMetadataInput" UpsertMetadataInput) + , nanoid :: NotNull (AsGql "ID" GraphQL.Client.ID.ID) + } + -> AsGql "Metadata" Metadata + , createAccount :: + { input :: NotNull (AsGql "CreateAccountInput" CreateAccountInput) } -> AsGql "Account" Account + } + +derive instance Data.Newtype.Newtype Mutation _ +newtype UpdateProfileInput = UpdateProfileInput + { bannerUrl :: AsGql "String" String + , displayName :: AsGql "String" String + , iconUrl :: AsGql "String" String + , summary :: AsGql "String" String + } + +derive instance Data.Newtype.Newtype UpdateProfileInput _ +newtype UpsertMetadataInput = UpsertMetadataInput + { content :: NotNull (AsGql "String" String), label :: NotNull (AsGql "String" String) } + +derive instance Data.Newtype.Newtype UpsertMetadataInput _ +newtype CreateAccountInput = CreateAccountInput + { isBot :: AsGql "Boolean" Boolean, name :: NotNull (AsGql "String" String) } + +derive instance Data.Newtype.Newtype CreateAccountInput _ diff --git a/src/Generated/Schema/Gql/Enum/ModerationType.purs b/src/Generated/Schema/Gql/Enum/ModerationType.purs new file mode 100644 index 0000000..68c12de --- /dev/null +++ b/src/Generated/Schema/Gql/Enum/ModerationType.purs @@ -0,0 +1,72 @@ +module Generated.Schema.Gql.Enum.ModerationType where + +import Prelude + +import Data.Argonaut.Decode (class DecodeJson, JsonDecodeError(..), decodeJson) +import Data.Argonaut.Encode (class EncodeJson, encodeJson) +import Data.Bounded (class Bounded) +import Data.Enum (class Enum, class BoundedEnum, Cardinality(..)) +import Data.Either (Either(..)) +import Data.Function (on) +import Data.Maybe (Maybe(..)) +import GraphQL.Client.ToGqlString (class GqlArgString) +import GraphQL.Hasura.Decode (class DecodeHasura) +import GraphQL.Hasura.Encode (class EncodeHasura) + + +data ModerationType + = SUSPENDED + | BANNED + + +instance eqModerationType :: Eq ModerationType where + eq = eq `on` show + +instance ordModerationType :: Ord ModerationType where + compare = compare `on` show + +instance gqlArgStringModerationType :: GqlArgString ModerationType where + toGqlArgStringImpl = show + +instance decodeJsonModerationType :: DecodeJson ModerationType where + decodeJson = decodeJson >=> case _ of + "SUSPENDED" -> pure SUSPENDED + "BANNED" -> pure BANNED + s -> Left $ TypeMismatch $ "Not a ModerationType: " <> s + +instance encodeJsonModerationType :: EncodeJson ModerationType where + encodeJson = show >>> encodeJson + +instance decdoeHasuraModerationType :: DecodeHasura ModerationType where + decodeHasura = decodeJson + +instance encodeHasuraModerationType :: EncodeHasura ModerationType where + encodeHasura = encodeJson + +instance showModerationType :: Show ModerationType where + show a = case a of + SUSPENDED -> "SUSPENDED" + BANNED -> "BANNED" + +instance enumModerationType :: Enum ModerationType where + succ a = case a of + SUSPENDED -> Just BANNED + BANNED -> Nothing + pred a = case a of + SUSPENDED -> Nothing + BANNED -> Just SUSPENDED + +instance boundedModerationType :: Bounded ModerationType where + top = BANNED + bottom = SUSPENDED + +instance boundedEnumModerationType :: BoundedEnum ModerationType where + cardinality = Cardinality 2 + toEnum a = case a of + 0 -> Just SUSPENDED + 1 -> Just BANNED + _ -> Nothing + fromEnum a = case a of + SUSPENDED -> 0 + BANNED -> 1 + diff --git a/src/Generated/Symbols.purs b/src/Generated/Symbols.purs new file mode 100644 index 0000000..568fd1f --- /dev/null +++ b/src/Generated/Symbols.purs @@ -0,0 +1,34 @@ +module Generated.Symbols where + +import Type.Proxy (Proxy(..)) + +account = Proxy :: Proxy "account" +accountId = Proxy :: Proxy "accountId" +accounts = Proxy :: Proxy "accounts" +bannedAt = Proxy :: Proxy "bannedAt" +bannerUrl = Proxy :: Proxy "bannerUrl" +content = Proxy :: Proxy "content" +createAccount = Proxy :: Proxy "createAccount" +createMetadata = Proxy :: Proxy "createMetadata" +createdAt = Proxy :: Proxy "createdAt" +deleteMetadata = Proxy :: Proxy "deleteMetadata" +displayName = Proxy :: Proxy "displayName" +expiresAt = Proxy :: Proxy "expiresAt" +first = Proxy :: Proxy "first" +iconUrl = Proxy :: Proxy "iconUrl" +id = Proxy :: Proxy "id" +isBot = Proxy :: Proxy "isBot" +items = Proxy :: Proxy "items" +label = Proxy :: Proxy "label" +last = Proxy :: Proxy "last" +metadata = Proxy :: Proxy "metadata" +moderation = Proxy :: Proxy "moderation" +name = Proxy :: Proxy "name" +nanoid = Proxy :: Proxy "nanoid" +profile = Proxy :: Proxy "profile" +publicKey = Proxy :: Proxy "publicKey" +reason = Proxy :: Proxy "reason" +summary = Proxy :: Proxy "summary" +suspendedAt = Proxy :: Proxy "suspendedAt" +updateMetadata = Proxy :: Proxy "updateMetadata" +updateProfile = Proxy :: Proxy "updateProfile" \ No newline at end of file From 2ab6f03c59921dadec48fd38df34846cd8ae4ecf Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 12:58:40 +0900 Subject: [PATCH 09/17] test(bff): add resolver, tristate, session, dataloader, and real-contract tests --- bff/emumet/real.test.ts | 354 ++++++++++++++++++++++++++++++++++++++++ bff/loaders.test.ts | 115 +++++++++++++ bff/resolvers.test.ts | 234 ++++++++++++++++++++++++++ bff/server.test.ts | 197 ++++++++++++++++++++++ bff/session.test.ts | 162 ++++++++++++++++++ bff/test-utils.ts | 42 +++++ 6 files changed, 1104 insertions(+) create mode 100644 bff/emumet/real.test.ts create mode 100644 bff/loaders.test.ts create mode 100644 bff/resolvers.test.ts create mode 100644 bff/server.test.ts create mode 100644 bff/session.test.ts create mode 100644 bff/test-utils.ts diff --git a/bff/emumet/real.test.ts b/bff/emumet/real.test.ts new file mode 100644 index 0000000..771d60d --- /dev/null +++ b/bff/emumet/real.test.ts @@ -0,0 +1,354 @@ +// ============================================================ +// (f) RealEmumetClient REST 契約テスト — global fetch を stub し、 +// 全メソッドの HTTP method/path/query/body/Authorization と +// snake_case → camelCase 変換を検証する +// (c) Tristate 変換 — undefined → フィールド省略 / null → JSON null / 値 → 値 +// (d) 204 → 再フェッチ — upsertProfile (PUT/POST) / updateMetadata で +// 書込み後に GET が続くことを呼出順序で検証する +// ============================================================ + +import { afterEach, describe, expect, test } from "bun:test"; +import { createRealEmumetClient } from "./real.ts"; +import { EmumetNotFoundError, type EmumetClient } from "./client.ts"; +import { jsonResponse, stubFetch, type FetchCall } from "../test-utils.ts"; + +const BASE = "http://emumet.test"; +const TOKEN = "test-access-token"; + +function makeClient(): EmumetClient { + return createRealEmumetClient({ baseUrl: BASE }, TOKEN); +} + +// ワイヤ fixture (snake_case) +const wireAccount1 = { + id: "acc_01", + name: "alice", + is_bot: false, + public_key: "ed25519:AAAA", + created_at: "2025-01-15T09:00:00Z", + moderation: null, +}; +const wireAccountSuspended = { + ...wireAccount1, + id: "acc_s", + moderation: { type: "suspended", reason: "spam", suspended_at: "2025-06-01T00:00:00Z", expires_at: "2025-07-01T00:00:00Z" }, +}; +const wireAccountBanned = { + ...wireAccount1, + id: "acc_b", + moderation: { type: "banned", reason: "tos", banned_at: "2025-06-02T00:00:00Z" }, +}; +const wireProfile1 = { + account_id: "acc_01", + nanoid: "prof_01", + display_name: "Alice", + summary: "hello", + icon_url: "https://icon.example.com/a", + banner_url: null, +}; +const wireMeta1 = { account_id: "acc_01", nanoid: "meta_01", label: "Website", content: "https://alice.example.com" }; + +function unexpected(call: FetchCall): never { + throw new Error(`unexpected fetch: ${call.method} ${call.url}`); +} + +const originalFetch = globalThis.fetch; +afterEach(() => { + Object.assign(globalThis, { fetch: originalFetch }); +}); + +describe("(f) REST contract — method/path/query/body/Authorization", () => { + test("listAccounts: GET /accounts with Bearer token, converts snake_case → camelCase incl. moderation", async () => { + // Given: 3 アカウント (moderation: null / suspended / banned) + const { calls } = stubFetch((call) => { + expect(call.method).toBe("GET"); + expect(call.url).toBe(`${BASE}/accounts`); + return jsonResponse(200, { items: [wireAccount1, wireAccountSuspended, wireAccountBanned], first: "acc_01", last: "acc_b" }); + }); + // When + const result = await makeClient().listAccounts(); + // Then: connection shape + camelCase 変換 + moderation マッピング + expect(calls).toHaveLength(1); + expect(calls[0]?.headers.Authorization).toBe(`Bearer ${TOKEN}`); + expect(calls[0]?.headers["Content-Type"]).toBeUndefined(); + expect(calls[0]?.body).toBeUndefined(); + expect(result.first).toBe("acc_01"); + expect(result.last).toBe("acc_b"); + expect(result.items).toHaveLength(3); + expect(result.items[0]).toEqual({ + id: "acc_01", + name: "alice", + isBot: false, + publicKey: "ed25519:AAAA", + createdAt: "2025-01-15T09:00:00Z", + moderation: null, + }); + expect(result.items[1]?.moderation).toEqual({ + type: "SUSPENDED", + reason: "spam", + suspendedAt: "2025-06-01T00:00:00Z", + expiresAt: "2025-07-01T00:00:00Z", + bannedAt: null, + }); + expect(result.items[2]?.moderation).toEqual({ + type: "BANNED", + reason: "tos", + suspendedAt: null, + expiresAt: null, + bannedAt: "2025-06-02T00:00:00Z", + }); + }); + + test("listAccounts: 404 \"No accounts found\" is normalized to empty connection", async () => { + // Given: Emumet は 0 件時に 404 を返す + stubFetch(() => new Response("No accounts found", { status: 404 })); + // When + const result = await makeClient().listAccounts(); + // Then: 空 connection に正規化 (throw しない) + expect(result).toEqual({ items: [], first: null, last: null }); + }); + + test("getAccount: GET /accounts?ids= returns first item converted", async () => { + // Given + const { calls } = stubFetch((call) => { + expect(call.method).toBe("GET"); + return jsonResponse(200, { items: [wireAccount1], first: null, last: null }); + }); + // When + const result = await makeClient().getAccount("acc_01"); + // Then: ids クエリで 1 件取得 + expect(calls[0]?.url).toBe(`${BASE}/accounts?ids=acc_01`); + expect(result?.id).toBe("acc_01"); + expect(result?.isBot).toBe(false); + }); + + test("getAccount: 404 → null", async () => { + // Given / When + stubFetch(() => new Response("not found", { status: 404 })); + const result = await makeClient().getAccount("nope"); + // Then + expect(result).toBeNull(); + }); + + test("getAccount: 200 with empty items → null", async () => { + // Given / When + stubFetch(() => jsonResponse(200, { items: [], first: null, last: null })); + const result = await makeClient().getAccount("nope"); + // Then + expect(result).toBeNull(); + }); + + test("createAccount: POST /accounts with snake_case body, isBot omitted → is_bot false", async () => { + // Given + const { calls } = stubFetch((call) => { + expect(call.method).toBe("POST"); + expect(call.url).toBe(`${BASE}/accounts`); + return jsonResponse(201, wireAccount1); + }); + // When + const result = await makeClient().createAccount({ name: "alice" }); + // Then: body は snake_case、is_bot デフォルト false、Content-Type 付き + expect(calls[0]?.headers["Content-Type"]).toBe("application/json"); + expect(JSON.parse(calls[0]?.body ?? "")).toEqual({ name: "alice", is_bot: false }); + expect(result.id).toBe("acc_01"); + }); + + test("createAccount: isBot true is sent as is_bot true", async () => { + // Given / When + const { calls } = stubFetch(() => jsonResponse(201, wireAccountBanned)); + await makeClient().createAccount({ name: "bot", isBot: true }); + // Then + expect(JSON.parse(calls[0]?.body ?? "")).toEqual({ name: "bot", is_bot: true }); + }); + + test("listProfiles: GET /profiles?account_ids=a,b batch param, converts to camelCase", async () => { + // Given + const { calls } = stubFetch((call) => { + expect(call.method).toBe("GET"); + return jsonResponse(200, [wireProfile1]); + }); + // When + const result = await makeClient().listProfiles(["acc_01", "acc_02"]); + // Then: バッチパラメータ (カンマ連結) + expect(calls[0]?.url).toBe(`${BASE}/profiles?account_ids=acc_01,acc_02`); + expect(result).toEqual([ + { + nanoid: "prof_01", + accountId: "acc_01", + displayName: "Alice", + summary: "hello", + iconUrl: "https://icon.example.com/a", + bannerUrl: null, + }, + ]); + }); + + test("listProfiles: empty ids → no fetch, empty result", async () => { + // Given + const { calls } = stubFetch(unexpected); + // When + const result = await makeClient().listProfiles([]); + // Then: fetch 自体が呼ばれない + expect(result).toEqual([]); + expect(calls).toHaveLength(0); + }); + + test("getProfile: returns first profile, null when absent", async () => { + // Given: 存在するケース + stubFetch(() => jsonResponse(200, [wireProfile1])); + // When / Then + expect((await makeClient().getProfile("acc_01"))?.nanoid).toBe("prof_01"); + }); + + test("getProfile: absent → null", async () => { + // Given / When + stubFetch(() => jsonResponse(200, [])); + // Then + expect(await makeClient().getProfile("nope")).toBeNull(); + }); + + test("listMetadata: GET /metadata?account_ids=a,b batch param", async () => { + // Given + const { calls } = stubFetch(() => jsonResponse(200, [wireMeta1])); + // When + const result = await makeClient().listMetadata(["acc_01", "acc_02"]); + // Then + expect(calls[0]?.method).toBe("GET"); + expect(calls[0]?.url).toBe(`${BASE}/metadata?account_ids=acc_01,acc_02`); + expect(result).toEqual([{ nanoid: "meta_01", accountId: "acc_01", label: "Website", content: "https://alice.example.com" }]); + }); + + test("listMetadata: empty ids → no fetch, empty result", async () => { + // Given / When + const { calls } = stubFetch(unexpected); + const result = await makeClient().listMetadata([]); + // Then + expect(result).toEqual([]); + expect(calls).toHaveLength(0); + }); + + test("createMetadata: POST /accounts/{id}/metadata with label/content body", async () => { + // Given + const { calls } = stubFetch((call) => { + expect(call.method).toBe("POST"); + return jsonResponse(201, wireMeta1); + }); + // When + const result = await makeClient().createMetadata("acc_01", { label: "Website", content: "https://alice.example.com" }); + // Then + expect(calls[0]?.url).toBe(`${BASE}/accounts/acc_01/metadata`); + expect(JSON.parse(calls[0]?.body ?? "")).toEqual({ label: "Website", content: "https://alice.example.com" }); + expect(result.nanoid).toBe("meta_01"); + expect(result.accountId).toBe("acc_01"); + }); + + test("deleteMetadata: DELETE 204 succeeds", async () => { + // Given + const { calls } = stubFetch(() => new Response(null, { status: 204 })); + // When / Then: throw しない + await makeClient().deleteMetadata("acc_01", "meta_01"); + expect(calls[0]?.method).toBe("DELETE"); + expect(calls[0]?.url).toBe(`${BASE}/accounts/acc_01/metadata/meta_01`); + expect(calls[0]?.headers.Authorization).toBe(`Bearer ${TOKEN}`); + }); + + test("deleteMetadata: 404 → EmumetNotFoundError", async () => { + // Given / When / Then + stubFetch(() => new Response("not found", { status: 404 })); + await expect(makeClient().deleteMetadata("acc_01", "meta_xx")).rejects.toBeInstanceOf(EmumetNotFoundError); + }); +}); + +describe("(c)(d) upsertProfile — Tristate 変換 + 204→再フェッチ", () => { + test("existing profile: GET → PUT 204 (tristate body) → GET refetch", async () => { + // Given: 既存 profile あり + const updatedWire = { ...wireProfile1, display_name: "Alicia", summary: null }; + const { calls } = stubFetch((call) => { + const url = new URL(call.url); + if (call.method === "GET" && url.pathname === "/profiles") { + // 1 回目 (存在確認) は旧値、2 回目 (再フェッチ) は更新後を返す + // (stub は handler 呼出前に calls へ push するため、自分を含め 2 件目以降が再フェッチ) + const isRefetch = calls.filter((c) => c.method === "GET").length > 1; + return jsonResponse(200, [isRefetch ? updatedWire : wireProfile1]); + } + if (call.method === "PUT") return new Response(null, { status: 204 }); + return unexpected(call); + }); + // When: displayName=値, summary=null (クリア), iconUrl/bannerUrl=undefined (省略) + const result = await makeClient().upsertProfile("acc_01", { displayName: "Alicia", summary: null }); + // Then: 呼出順序は GET → PUT → GET (204 のあと再フェッチ) + expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual([ + "GET /profiles", + "PUT /accounts/acc_01/profile", + "GET /profiles", + ]); + // And: (c) Tristate — undefined の icon_url/banner_url は body に存在せず、summary は JSON null + const putBody = JSON.parse(calls[1]?.body ?? "") as Record; + expect(putBody).toEqual({ display_name: "Alicia", summary: null }); + expect(Object.keys(putBody).sort()).toEqual(["display_name", "summary"]); + expect("icon_url" in putBody).toBe(false); + expect("banner_url" in putBody).toBe(false); + // And: 戻り値は再フェッチ結果 + expect(result.displayName).toBe("Alicia"); + expect(result.summary).toBeNull(); + }); + + test("absent profile: GET → POST 201 → GET refetch", async () => { + // Given: profile 未存在 + const createdWire = { ...wireProfile1, display_name: "New", summary: null, icon_url: null }; + const { calls } = stubFetch((call) => { + const url = new URL(call.url); + if (call.method === "GET" && url.pathname === "/profiles") { + const isRefetch = calls.filter((c) => c.method === "GET").length > 1; + return jsonResponse(200, isRefetch ? [createdWire] : []); + } + if (call.method === "POST") return new Response(null, { status: 201 }); + return unexpected(call); + }); + // When + const result = await makeClient().upsertProfile("acc_01", { displayName: "New" }); + // Then: 未存在時は POST が選ばれ、そのあと再フェッチ + expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual([ + "GET /profiles", + "POST /accounts/acc_01/profile", + "GET /profiles", + ]); + expect(JSON.parse(calls[1]?.body ?? "")).toEqual({ display_name: "New" }); + expect(result.displayName).toBe("New"); + }); +}); + +describe("(d) updateMetadata — PUT 204 → listMetadata 再フェッチ", () => { + test("PUT 204 is followed by GET refetch, result is the matching nanoid", async () => { + // Given: 再フェッチ応答に対象 nanoid を含む + const updatedWire = { ...wireMeta1, content: "https://new.example.com" }; + const { calls } = stubFetch((call) => { + const url = new URL(call.url); + if (call.method === "PUT") return new Response(null, { status: 204 }); + if (call.method === "GET" && url.pathname === "/metadata") return jsonResponse(200, [updatedWire]); + return unexpected(call); + }); + // When + const result = await makeClient().updateMetadata("acc_01", "meta_01", { label: "Website", content: "https://new.example.com" }); + // Then: PUT → GET の順、PUT body は label/content + expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual([ + "PUT /accounts/acc_01/metadata/meta_01", + "GET /metadata", + ]); + expect(new URL(calls[1]?.url ?? "").search).toBe("?account_ids=acc_01"); + expect(JSON.parse(calls[0]?.body ?? "")).toEqual({ label: "Website", content: "https://new.example.com" }); + expect(result.content).toBe("https://new.example.com"); + }); + + test("refetch missing the nanoid → EmumetNotFoundError", async () => { + // Given: 再フェッチ応答に対象 nanoid が無い + stubFetch((call) => { + if (call.method === "PUT") return new Response(null, { status: 204 }); + return jsonResponse(200, [{ ...wireMeta1, nanoid: "meta_other" }]); + }); + // When / Then + await expect( + makeClient().updateMetadata("acc_01", "meta_01", { label: "L", content: "C" }), + ).rejects.toBeInstanceOf(EmumetNotFoundError); + }); +}); diff --git a/bff/loaders.test.ts b/bff/loaders.test.ts new file mode 100644 index 0000000..0bd8c3f --- /dev/null +++ b/bff/loaders.test.ts @@ -0,0 +1,115 @@ +// ============================================================ +// (e) DataLoader batch 契約テスト +// バッチ応答を keys と逆順 + 欠損キー混在で返す fake client に対し: +// - 各キーに正しい値が対応する +// - 返却配列が keys と同長 +// - 欠損キーは profile → null / metadata → [] +// - listProfiles/listMetadata は keys 全件で 1 回だけ呼ばれる (バッチング) +// ============================================================ + +import { describe, expect, test } from "bun:test"; +import { makeLoaders } from "./loaders.ts"; +import type { EmumetClient, Metadata, Profile } from "./emumet/client.ts"; + +function notUsed(name: string): never { + throw new Error(`${name} must not be called by loaders`); +} + +type BatchOverrides = { + listProfiles: (ids: readonly string[]) => Promise; + listMetadata: (ids: readonly string[]) => Promise; +}; + +// loaders が使う 2 メソッド以外は呼ばれたら即失敗する fake +function fakeEmumetClient(overrides: BatchOverrides): EmumetClient { + return { + listAccounts: () => notUsed("listAccounts"), + getAccount: () => notUsed("getAccount"), + createAccount: () => notUsed("createAccount"), + getProfile: () => notUsed("getProfile"), + upsertProfile: () => notUsed("upsertProfile"), + createMetadata: () => notUsed("createMetadata"), + updateMetadata: () => notUsed("updateMetadata"), + deleteMetadata: () => notUsed("deleteMetadata"), + ...overrides, + }; +} + +const profile1: Profile = { + nanoid: "prof_k1", + accountId: "k1", + displayName: "One", + summary: null, + iconUrl: null, + bannerUrl: null, +}; +const profile2: Profile = { + nanoid: "prof_k2", + accountId: "k2", + displayName: "Two", + summary: "s2", + iconUrl: null, + bannerUrl: null, +}; +const meta1: Metadata = { nanoid: "m1", accountId: "k1", label: "l1", content: "c1" }; +const meta2a: Metadata = { nanoid: "m2a", accountId: "k2", label: "l2a", content: "c2a" }; +const meta2b: Metadata = { nanoid: "m2b", accountId: "k2", label: "l2b", content: "c2b" }; + +function makeReversedBatchClient(calls: { profile: string[][]; metadata: string[][] }): EmumetClient { + return fakeEmumetClient({ + listProfiles: (ids) => { + calls.profile.push([...ids]); + // keys [k1,k2,k3] に対し逆順 + k3 欠損で返す + return Promise.resolve([profile2, profile1]); + }, + listMetadata: (ids) => { + calls.metadata.push([...ids]); + // 逆順・グループ混在 + k3 欠損で返す + return Promise.resolve([meta2b, meta1, meta2a]); + }, + }); +} + +describe("(e) DataLoader batch contract", () => { + test("profile loader realigns reversed batch response and maps missing key to null", async () => { + // Given: keys [k1,k2,k3]、応答は逆順 [k2,k1] で k3 欠損 + const calls = { profile: [] as string[][], metadata: [] as string[][] }; + const loaders = makeLoaders(makeReversedBatchClient(calls)); + // When: 3 キーを一括 load + const results = await loaders.profile.loadMany(["k1", "k2", "k3"]); + // Then: keys と同長、各キーに正しい値、欠損は該当位置が null + expect(results).toHaveLength(3); + expect(results[0]).toEqual(profile1); + expect(results[1]).toEqual(profile2); + expect(results[2]).toBeNull(); + // And: バッチは keys 全件でちょうど 1 回 + expect(calls.profile).toEqual([["k1", "k2", "k3"]]); + }); + + test("metadata loader realigns reversed batch response and maps missing key to []", async () => { + // Given: 同上 (k2 に 2 件、k1 に 1 件、k3 欠損) + const calls = { profile: [] as string[][], metadata: [] as string[][] }; + const loaders = makeLoaders(makeReversedBatchClient(calls)); + // When + const results = await loaders.metadata.loadMany(["k1", "k2", "k3"]); + // Then: keys と同長、グルーピング正対応、欠損は [] + expect(results).toHaveLength(3); + expect(results[0]).toEqual([meta1]); + // グループ内順序は応答の出現順 (meta2b が先) を維持する + expect(results[1]).toEqual([meta2b, meta2a]); + expect(results[2]).toEqual([]); + expect(calls.metadata).toEqual([["k1", "k2", "k3"]]); + }); + + test("separate load() calls in the same tick are batched into one listProfiles call", async () => { + // Given: 同一 loader インスタンス + const calls = { profile: [] as string[][], metadata: [] as string[][] }; + const loaders = makeLoaders(makeReversedBatchClient(calls)); + // When: 同一 tick で個別 load + const [r1, r2] = await Promise.all([loaders.profile.load("k1"), loaders.profile.load("k2")]); + // Then: 1 回のバッチにまとまり各キーに正対応 + expect(r1).toEqual(profile1); + expect(r2).toEqual(profile2); + expect(calls.profile).toEqual([["k1", "k2"]]); + }); +}); diff --git a/bff/resolvers.test.ts b/bff/resolvers.test.ts new file mode 100644 index 0000000..bf71c7d --- /dev/null +++ b/bff/resolvers.test.ts @@ -0,0 +1,234 @@ +// ============================================================ +// (a) リゾルバ形状テスト — MockEmumetClient 差し込みで Query/Mutation を実行 +// (b) UNAUTHENTICATED 分岐 — context.emumet null → extensions.code UNAUTHENTICATED +// ============================================================ + +import { describe, expect, test } from "bun:test"; +import { graphql, type GraphQLSchema } from "graphql"; +import { makeSchema } from "./schema.ts"; +import { resolvers } from "./resolvers.ts"; +import { buildContext, type GraphQLContext } from "./context.ts"; +import { createMockEmumetClient } from "./emumet/mock.ts"; +import type { EmumetClient } from "./emumet/client.ts"; +import type { SessionAdapter } from "./session.ts"; + +const schema: GraphQLSchema = makeSchema(resolvers); + +// 境界 fake: buildContext の本物の分岐 (fresh → authenticatedContext) を通すための +// 最小 SessionAdapter。DataLoader の遅延メモ化も本物の実装が使われる。 +const stubAdapter: SessionAdapter = { + getSession: () => Promise.resolve({ accessToken: "test-token" }), + refreshSessionIfNeeded: (session) => Promise.resolve({ kind: "fresh", accessToken: session.accessToken }), + sealSessionCookie: () => Promise.resolve(""), + clearSessionCookie: () => "", +}; + +const nullSessionAdapter: SessionAdapter = { + ...stubAdapter, + getSession: () => Promise.resolve(null), +}; + +async function authenticatedContext(emumet: EmumetClient): Promise { + return buildContext(new Request("http://localhost/graphql"), { + adapter: stubAdapter, + createEmumetClient: () => emumet, + }); +} + +async function unauthenticatedContext(): Promise { + return buildContext(new Request("http://localhost/graphql"), { + adapter: nullSessionAdapter, + createEmumetClient: () => createMockEmumetClient(), + }); +} + +function run(source: string, context: GraphQLContext) { + return graphql({ schema, source, contextValue: context }); +} + +describe("(a) resolvers with MockEmumetClient", () => { + test("Query.accounts returns connection shape with seed accounts", async () => { + // Given: mock client seeded with acc_01..03 + const context = await authenticatedContext(createMockEmumetClient()); + // When: accounts query + const result = await run("{ accounts { items { id name isBot publicKey createdAt moderation { type } } first last } }", context); + // Then: connection shape, 3 seed items, moderation null + expect(result.errors).toBeUndefined(); + const accounts = result.data?.accounts as { + items: { id: string; name: string; isBot: boolean; publicKey: string; createdAt: string; moderation: unknown }[]; + first: string | null; + last: string | null; + }; + expect(accounts.first).toBeNull(); + expect(accounts.last).toBeNull(); + expect(accounts.items.map((a) => a.id)).toEqual(["acc_01", "acc_02", "acc_03"]); + expect(accounts.items.map((a) => a.name)).toEqual(["alice", "bob", "bot-news"]); + expect(accounts.items[2]?.isBot).toBe(true); + expect(accounts.items[0]?.publicKey).toBe("ed25519:AAAA"); + expect(accounts.items[0]?.moderation).toBeNull(); + }); + + test("Query.account returns account with profile and metadata via DataLoader", async () => { + // Given: acc_01 has prof_01 and meta_01/meta_02 + const context = await authenticatedContext(createMockEmumetClient()); + // When: nested account query + const result = await run( + '{ account(id: "acc_01") { id name profile { nanoid displayName summary } metadata { nanoid label content } } }', + context, + ); + // Then: nested profile/metadata resolved + expect(result.errors).toBeUndefined(); + const account = result.data?.account as { + id: string; + profile: { nanoid: string; displayName: string | null; summary: string | null }; + metadata: { nanoid: string; label: string; content: string }[]; + }; + expect(account.id).toBe("acc_01"); + expect(account.profile.nanoid).toBe("prof_01"); + expect(account.profile.displayName).toBe("Alice Wonderland"); + expect(account.metadata.map((m) => m.nanoid)).toEqual(["meta_01", "meta_02"]); + expect(account.metadata[0]?.label).toBe("Website"); + }); + + test("Query.account returns null for unknown id", async () => { + // Given / When: unknown id + const context = await authenticatedContext(createMockEmumetClient()); + const result = await run('{ account(id: "nope") { id } }', context); + // Then: null, no errors + expect(result.errors).toBeUndefined(); + expect(result.data?.account).toBeNull(); + }); + + test("Mutation.createAccount returns created account with generated id", async () => { + // Given / When: createAccount + const context = await authenticatedContext(createMockEmumetClient()); + const result = await run( + 'mutation { createAccount(input: { name: "carol" }) { id name isBot publicKey moderation { type } } }', + context, + ); + // Then: id acc_100 (counter starts at 100), isBot defaults false + expect(result.errors).toBeUndefined(); + const account = result.data?.createAccount as { id: string; name: string; isBot: boolean; publicKey: string; moderation: unknown }; + expect(account.id).toBe("acc_100"); + expect(account.name).toBe("carol"); + expect(account.isBot).toBe(false); + expect(account.publicKey).toBe("ed25519:MOCK_acc_100"); + expect(account.moderation).toBeNull(); + }); + + test("Mutation.updateProfile updates existing profile (null clears, omitted keeps)", async () => { + // Given: acc_01 profile with displayName/summary set + const context = await authenticatedContext(createMockEmumetClient()); + // When: update with summary null (clear) and iconUrl omitted (keep) + const result = await run( + 'mutation { updateProfile(accountId: "acc_01", input: { displayName: "Alicia", summary: null }) { nanoid displayName summary iconUrl } }', + context, + ); + // Then: displayName updated, summary cleared, iconUrl kept, nanoid unchanged + expect(result.errors).toBeUndefined(); + const profile = result.data?.updateProfile as { nanoid: string; displayName: string | null; summary: string | null; iconUrl: string | null }; + expect(profile.nanoid).toBe("prof_01"); + expect(profile.displayName).toBe("Alicia"); + expect(profile.summary).toBeNull(); + expect(profile.iconUrl).toBe("https://api.dicebear.com/9.x/thumbs/svg?seed=alice"); + }); + + test("Mutation.updateProfile creates profile when absent (upsert POST path)", async () => { + // Given: fresh account without profile + const emumet = createMockEmumetClient(); + const context = await authenticatedContext(emumet); + await run('mutation { createAccount(input: { name: "carol" }) { id } }', context); + // When: updateProfile on the new account + const result = await run( + 'mutation { updateProfile(accountId: "acc_100", input: { displayName: "Carol" }) { nanoid accountId displayName summary } }', + context, + ); + // Then: profile created, unspecified fields null + expect(result.errors).toBeUndefined(); + const profile = result.data?.updateProfile as { nanoid: string; accountId: string; displayName: string | null; summary: string | null }; + expect(profile.accountId).toBe("acc_100"); + expect(profile.displayName).toBe("Carol"); + expect(profile.summary).toBeNull(); + }); + + test("Mutation.createMetadata returns created metadata", async () => { + // Given / When + const context = await authenticatedContext(createMockEmumetClient()); + const result = await run( + 'mutation { createMetadata(accountId: "acc_02", input: { label: "Blog", content: "https://blog.example.com" }) { nanoid accountId label content } }', + context, + ); + // Then + expect(result.errors).toBeUndefined(); + const metadata = result.data?.createMetadata as { nanoid: string; accountId: string; label: string; content: string }; + expect(metadata.nanoid).toBe("meta_100"); + expect(metadata.accountId).toBe("acc_02"); + expect(metadata.label).toBe("Blog"); + expect(metadata.content).toBe("https://blog.example.com"); + }); + + test("Mutation.updateMetadata returns updated metadata", async () => { + // Given / When: update existing meta_01 + const context = await authenticatedContext(createMockEmumetClient()); + const result = await run( + 'mutation { updateMetadata(accountId: "acc_01", nanoid: "meta_01", input: { label: "Web", content: "https://new.example.com" }) { nanoid label content } }', + context, + ); + // Then + expect(result.errors).toBeUndefined(); + const metadata = result.data?.updateMetadata as { nanoid: string; label: string; content: string }; + expect(metadata).toEqual({ nanoid: "meta_01", label: "Web", content: "https://new.example.com" }); + }); + + test("Mutation.updateMetadata on missing nanoid maps to NOT_FOUND", async () => { + // Given / When: missing nanoid + const context = await authenticatedContext(createMockEmumetClient()); + const result = await run( + 'mutation { updateMetadata(accountId: "acc_01", nanoid: "meta_xx", input: { label: "Web", content: "x" }) { nanoid } }', + context, + ); + // Then: EmumetNotFoundError → NOT_FOUND (非 null フィールドのため data 全体が null) + expect(result.data).toBeNull(); + expect(result.errors?.[0]?.extensions?.code).toBe("NOT_FOUND"); + }); + + test("Mutation.deleteMetadata returns true", async () => { + // Given / When: delete existing meta_02 + const context = await authenticatedContext(createMockEmumetClient()); + const result = await run('mutation { deleteMetadata(accountId: "acc_01", nanoid: "meta_02") }', context); + // Then + expect(result.errors).toBeUndefined(); + expect(result.data?.deleteMetadata).toBe(true); + }); + + test("Mutation.deleteMetadata on missing nanoid maps to NOT_FOUND", async () => { + // Given / When + const context = await authenticatedContext(createMockEmumetClient()); + const result = await run('mutation { deleteMetadata(accountId: "acc_01", nanoid: "meta_xx") }', context); + // Then (非 null フィールドのため data 全体が null) + expect(result.data).toBeNull(); + expect(result.errors?.[0]?.extensions?.code).toBe("NOT_FOUND"); + }); +}); + +describe("(b) UNAUTHENTICATED branch", () => { + test("Query.accounts with null emumet yields UNAUTHENTICATED", async () => { + // Given: session なし (buildContext → emumet null) + const context = await unauthenticatedContext(); + // When + const result = await run("{ accounts { items { id } } }", context); + // Then: GraphQLError extensions.code UNAUTHENTICATED, data null + expect(context.emumet).toBeNull(); + expect(result.data).toBeNull(); + expect(result.errors?.[0]?.extensions?.code).toBe("UNAUTHENTICATED"); + }); + + test("Mutation.createAccount with null emumet yields UNAUTHENTICATED", async () => { + // Given / When + const context = await unauthenticatedContext(); + const result = await run('mutation { createAccount(input: { name: "x" }) { id } }', context); + // Then + expect(result.data).toBeNull(); + expect(result.errors?.[0]?.extensions?.code).toBe("UNAUTHENTICATED"); + }); +}); diff --git a/bff/server.test.ts b/bff/server.test.ts new file mode 100644 index 0000000..6804a92 --- /dev/null +++ b/bff/server.test.ts @@ -0,0 +1,197 @@ +// ============================================================ +// (g-1) yoga ハンドラ統合 (Real) — 暗号化セッション cookie を載せた +// リクエストで refresh 4 シナリオの Set-Cookie / token 使用を検証 +// (g-2) yoga ハンドラ統合 (Mock) — encodeMockCookie 形式の cookie で +// 認証済みクエリ成功 / cookie 無しで UNAUTHENTICATED +// ============================================================ + +import { afterEach, describe, expect, test } from "bun:test"; +import { createYogaHandler } from "./server.ts"; +import { + createMockSessionAdapter, + createSessionAdapter, + encodeMockCookie, + type AppSession, + type SessionAdapterConfig, +} from "./session.ts"; +import { createMockEmumetClient } from "./emumet/mock.ts"; +import { jsonResponse, stubFetch } from "./test-utils.ts"; + +const FIXED_KEY_BASE64 = btoa("0123456789abcdef0123456789abcdef"); + +const realConfig: SessionAdapterConfig = { + cookieSecretBase64: FIXED_KEY_BASE64, + sessionCookieName: "ratcap_session", + isSecureOrigin: false, + hydraPublicUrl: "http://hydra.test", + hydraClientId: "test-client", + hydraClientSecret: "test-secret", + refreshSkewSeconds: 60, +}; + +function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +function makeSession(overrides: Partial = {}): AppSession { + return { + v: 1, + sub: "user-1", + email: "alice@example.com", + accessToken: "access-1", + refreshToken: "refresh-1", + tokenType: "Bearer", + scope: "openid offline_access", + expiresAt: nowSeconds() + 3600, + ...overrides, + }; +} + +type GqlBody = { + data?: { accounts?: { items: { id: string; name: string }[] } | null } | null; + errors?: { message: string; extensions?: { code?: string } }[]; +}; + +const ACCOUNTS_QUERY = "{ accounts { items { id name } } }"; + +async function postGraphql( + handler: (req: Request) => Promise, + cookie?: string, +): Promise<{ status: number; setCookie: string | null; body: GqlBody }> { + const headers: Record = { "content-type": "application/json" }; + if (cookie !== undefined) headers.cookie = cookie; + const res = await handler( + new Request("http://localhost/graphql", { + method: "POST", + headers, + body: JSON.stringify({ query: ACCOUNTS_QUERY }), + }), + ); + const body: GqlBody = await res.json(); + return { status: res.status, setCookie: res.headers.get("set-cookie"), body }; +} + +// Real 統合用: adapter + token を捕捉する EmumetClient ファクトリでハンドラを組み立てる +function makeRealHandler(captured: { token: string | null }) { + const adapter = createSessionAdapter(realConfig); + const emumet = createMockEmumetClient(); + const handler = createYogaHandler(adapter, (accessToken) => { + captured.token = accessToken; + return emumet; + }); + return { adapter, handler }; +} + +async function sealedCookie(adapter: ReturnType, session: AppSession): Promise { + const header = await adapter.sealSessionCookie(session); + return `ratcap_session=${header.split(";")[0].split("=")[1]}`; +} + +const originalFetch = globalThis.fetch; +afterEach(() => { + Object.assign(globalThis, { fetch: originalFetch }); +}); + +describe("(g-1) yoga handler with encrypted session cookie (Real)", () => { + test("refresh success → Set-Cookie with refreshed sealed session, new token used", async () => { + // Given: 期限間近セッション + 成功する Hydra stub + stubFetch(() => + jsonResponse(200, { + access_token: "access-2", + refresh_token: "refresh-2", + expires_in: 3600, + scope: "openid offline_access", + token_type: "Bearer", + }), + ); + const captured: { token: string | null } = { token: null }; + const { adapter, handler } = makeRealHandler(captured); + const cookie = await sealedCookie(adapter, makeSession({ expiresAt: nowSeconds() + 10 })); + // When + const { status, setCookie, body } = await postGraphql(handler, cookie); + // Then: Set-Cookie 付与 (既存属性形式)、新 token で EmumetClient 生成、クエリ成功 + expect(status).toBe(200); + expect(setCookie).toMatch(/^ratcap_session=[A-Za-z0-9_-]+; Path=\/; HttpOnly; SameSite=Lax$/); + expect(captured.token).toBe("access-2"); + expect(body.errors).toBeUndefined(); + expect(body.data?.accounts?.items).toHaveLength(3); + }); + + test("no refresh needed → no Set-Cookie, current token used", async () => { + // Given: 期限に余裕のあるセッション (fetch は呼ばれない) + stubFetch(() => { + throw new Error("fetch must not be called for a fresh session"); + }); + const captured: { token: string | null } = { token: null }; + const { adapter, handler } = makeRealHandler(captured); + const cookie = await sealedCookie(adapter, makeSession()); + // When + const { status, setCookie, body } = await postGraphql(handler, cookie); + // Then + expect(status).toBe(200); + expect(setCookie).toBeNull(); + expect(captured.token).toBe("access-1"); + expect(body.data?.accounts?.items).toHaveLength(3); + }); + + test("refresh failed + not expired → no Set-Cookie, current token used", async () => { + // Given: 期限間近だが未期限切れ + 失敗する Hydra stub + stubFetch(() => new Response("invalid_grant", { status: 400 })); + const captured: { token: string | null } = { token: null }; + const { adapter, handler } = makeRealHandler(captured); + const cookie = await sealedCookie(adapter, makeSession({ expiresAt: nowSeconds() + 30 })); + // When + const { status, setCookie, body } = await postGraphql(handler, cookie); + // Then: 現 token で継続、Set-Cookie なし + expect(status).toBe(200); + expect(setCookie).toBeNull(); + expect(captured.token).toBe("access-1"); + expect(body.data?.accounts?.items).toHaveLength(3); + }); + + test("refresh failed + expired → clear-cookie Set-Cookie AND UNAUTHENTICATED error", async () => { + // Given: 期限切れ + 失敗する Hydra stub + stubFetch(() => new Response("invalid_grant", { status: 400 })); + const captured: { token: string | null } = { token: null }; + const { adapter, handler } = makeRealHandler(captured); + const cookie = await sealedCookie(adapter, makeSession({ expiresAt: nowSeconds() - 10 })); + // When + const { status, setCookie, body } = await postGraphql(handler, cookie); + // Then: Max-Age=0 の clear-cookie + UNAUTHENTICATED、EmumetClient は生成されない + expect(status).toBe(200); + expect(setCookie).toBe("ratcap_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"); + expect(captured.token).toBeNull(); + expect(body.data).toBeNull(); + expect(body.errors?.[0]?.extensions?.code).toBe("UNAUTHENTICATED"); + }); +}); + +describe("(g-2) yoga handler with mock session cookie (Mock)", () => { + test("mock cookie (encodeMockCookie format) → authenticated accounts query succeeds", async () => { + // Given: handleMockAuth と同形式の base64 mock cookie + const adapter = createMockSessionAdapter(); + const emumet = createMockEmumetClient(); + const handler = createYogaHandler(adapter, () => emumet); + const cookie = `ratcap_session=${encodeMockCookie({ token: "mock-token", username: "alice" })}`; + // When + const { status, setCookie, body } = await postGraphql(handler, cookie); + // Then: MockEmumetClient のシードデータが返る (mock は refresh no-op → Set-Cookie なし) + expect(status).toBe(200); + expect(setCookie).toBeNull(); + expect(body.errors).toBeUndefined(); + expect(body.data?.accounts?.items.map((a) => a.name)).toEqual(["alice", "bob", "bot-news"]); + }); + + test("no cookie → UNAUTHENTICATED", async () => { + // Given: cookie 無し + const adapter = createMockSessionAdapter(); + const handler = createYogaHandler(adapter, () => createMockEmumetClient()); + // When + const { status, setCookie, body } = await postGraphql(handler); + // Then + expect(status).toBe(200); + expect(setCookie).toBeNull(); + expect(body.data).toBeNull(); + expect(body.errors?.[0]?.extensions?.code).toBe("UNAUTHENTICATED"); + }); +}); diff --git a/bff/session.test.ts b/bff/session.test.ts new file mode 100644 index 0000000..6a3778f --- /dev/null +++ b/bff/session.test.ts @@ -0,0 +1,162 @@ +// ============================================================ +// (g-1) Real セッションアダプタ直接テスト — 固定鍵での seal/unseal +// ラウンドトリップと refresh 4 分岐 (stub fetch 使用) +// ============================================================ + +import { afterEach, describe, expect, test } from "bun:test"; +import { + createSessionAdapter, + type AppSession, + type SessionAdapterConfig, +} from "./session.ts"; +import { jsonResponse, stubFetch } from "./test-utils.ts"; + +// 固定鍵 (32 バイト "0..f" の base64) — テスト間で seal/unseal が往復できることだけが要件 +const FIXED_KEY_BASE64 = btoa("0123456789abcdef0123456789abcdef"); + +const config: SessionAdapterConfig = { + cookieSecretBase64: FIXED_KEY_BASE64, + sessionCookieName: "ratcap_session", + isSecureOrigin: false, + hydraPublicUrl: "http://hydra.test", + hydraClientId: "test-client", + hydraClientSecret: "test-secret", + refreshSkewSeconds: 60, +}; + +function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +function makeSession(overrides: Partial = {}): AppSession { + return { + v: 1, + sub: "user-1", + email: "alice@example.com", + accessToken: "access-1", + refreshToken: "refresh-1", + tokenType: "Bearer", + scope: "openid offline_access", + expiresAt: nowSeconds() + 3600, + ...overrides, + }; +} + +function cookieValueFromHeader(header: string): string { + return header.split(";")[0].split("=")[1]; +} + +const originalFetch = globalThis.fetch; +afterEach(() => { + Object.assign(globalThis, { fetch: originalFetch }); +}); + +describe("(g-1) createSessionAdapter — seal/unseal roundtrip", () => { + test("sealSessionCookie → getSession roundtrips the session with a fixed key", async () => { + // Given: 固定鍵のアダプタとセッション + const adapter = createSessionAdapter(config); + const session = makeSession(); + // When: seal して cookie ヘッダに載せて getSession + const header = await adapter.sealSessionCookie(session); + const req = new Request("http://localhost/graphql", { + headers: { cookie: `ratcap_session=${cookieValueFromHeader(header)}` }, + }); + const restored = await adapter.getSession(req); + // Then: 元セッションと一致、ヘッダは既存属性形式 + expect(restored).toEqual(session); + expect(header).toMatch(/^ratcap_session=[A-Za-z0-9_-]+; Path=\/; HttpOnly; SameSite=Lax$/); + }); + + test("getSession returns null without cookie and with a tampered cookie", async () => { + // Given + const adapter = createSessionAdapter(config); + // When / Then: cookie なし → null + expect(await adapter.getSession(new Request("http://localhost/graphql"))).toBeNull(); + // And: 復号不能な値 → null (throw しない) + const tampered = new Request("http://localhost/graphql", { + headers: { cookie: "ratcap_session=not-a-valid-sealed-value" }, + }); + expect(await adapter.getSession(tampered)).toBeNull(); + }); +}); + +describe("(g-1) refreshSessionIfNeeded 4 分岐", () => { + test("fresh token: no fetch, kind fresh with current accessToken", async () => { + // Given: 期限に余裕のあるセッション + const adapter = createSessionAdapter(config); + const { calls } = stubFetch(() => { + throw new Error("fetch must not be called for a fresh session"); + }); + // When + const outcome = await adapter.refreshSessionIfNeeded(makeSession()); + // Then: refresh せず現 token、fetch 0 回 + expect(outcome).toEqual({ kind: "fresh", accessToken: "access-1" }); + expect(calls).toHaveLength(0); + }); + + test("expiring token: refresh via stub fetch yields refreshed kind with sealed cookie", async () => { + // Given: 期限間近のセッションと成功する Hydra stub + const adapter = createSessionAdapter(config); + const { calls } = stubFetch((call) => { + expect(call.method).toBe("POST"); + expect(call.url).toBe("http://hydra.test/oauth2/token"); + return jsonResponse(200, { + access_token: "access-2", + refresh_token: "refresh-2", + expires_in: 3600, + scope: "openid offline_access", + token_type: "Bearer", + }); + }); + // When + const outcome = await adapter.refreshSessionIfNeeded(makeSession({ expiresAt: nowSeconds() + 10 })); + // Then: refreshed、新 token、seal 済み Set-Cookie 文字列 + expect(outcome.kind).toBe("refreshed"); + if (outcome.kind !== "refreshed") return; + expect(outcome.accessToken).toBe("access-2"); + expect(outcome.sessionCookieHeader).toMatch(/^ratcap_session=[A-Za-z0-9_-]+; Path=\/; HttpOnly; SameSite=Lax$/); + // And: Hydra への要求は refresh_token grant + Basic 認証 + expect(calls).toHaveLength(1); + const body = new URLSearchParams(calls[0]?.body); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("refresh-1"); + expect(calls[0]?.headers.Authorization).toBe(`Basic ${btoa("test-client:test-secret")}`); + }); + + test("refresh failure with unexpired session: kind refresh-failed-active, current token kept", async () => { + // Given: 期限間近だが未期限切れ + 失敗する Hydra stub + const adapter = createSessionAdapter(config); + stubFetch(() => new Response("invalid_grant", { status: 400 })); + // When + const outcome = await adapter.refreshSessionIfNeeded(makeSession({ expiresAt: nowSeconds() + 30 })); + // Then: 現 token をそのまま使用 + expect(outcome).toEqual({ kind: "refresh-failed-active", accessToken: "access-1" }); + }); + + test("refresh failure with expired session: kind refresh-failed-expired with clear-cookie", async () => { + // Given: 期限切れ + 失敗する Hydra stub + const adapter = createSessionAdapter(config); + stubFetch(() => new Response("invalid_grant", { status: 400 })); + // When + const outcome = await adapter.refreshSessionIfNeeded(makeSession({ expiresAt: nowSeconds() - 10 })); + // Then: clear-cookie (Max-Age=0) + expect(outcome.kind).toBe("refresh-failed-expired"); + if (outcome.kind !== "refresh-failed-expired") return; + expect(outcome.sessionCookieHeader).toBe("ratcap_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"); + }); + + test("expiring session without refreshToken: refresh-failed-active (no fetch)", async () => { + // Given: refreshToken 無し (refreshWithHydra は即 null) + const adapter = createSessionAdapter(config); + const { calls } = stubFetch(() => { + throw new Error("fetch must not be called without a refresh token"); + }); + // When + const session = makeSession({ expiresAt: nowSeconds() + 30 }); + delete session.refreshToken; + const outcome = await adapter.refreshSessionIfNeeded(session); + // Then + expect(outcome).toEqual({ kind: "refresh-failed-active", accessToken: "access-1" }); + expect(calls).toHaveLength(0); + }); +}); diff --git a/bff/test-utils.ts b/bff/test-utils.ts new file mode 100644 index 0000000..cfb06a4 --- /dev/null +++ b/bff/test-utils.ts @@ -0,0 +1,42 @@ +// ============================================================ +// テスト共有ヘルパー — global fetch の stub と JSON Response 生成 +// bun test からのみ import される (本番コードからは参照しない)。 +// ============================================================ + +export type FetchCall = { + readonly url: string; + readonly method: string; + readonly headers: Record; + readonly body: string | undefined; +}; + +export type FetchHandler = (call: FetchCall) => Response | Promise; + +/** global fetch を差し替え、全呼出を記録する。戻り値の calls で順序・内容を検証する */ +export function stubFetch(handler: FetchHandler): { calls: FetchCall[] } { + const calls: FetchCall[] = []; + const stub = (input: string | URL | Request, init?: RequestInit): Promise => { + // stub 境界: 被テスト実装 (real.ts / session.ts) は常にプレーンオブジェクトの + // headers を渡すため、ここでその形に絞る。body は string (real.ts) と + // URLSearchParams (session.ts) の両方を文字列に正規化する + const headers = (init?.headers ?? {}) as Record; + const rawBody = init?.body; + const call: FetchCall = { + url: String(input), + method: init?.method ?? "GET", + headers, + body: + typeof rawBody === "string" ? rawBody + : rawBody instanceof URLSearchParams ? rawBody.toString() + : undefined, + }; + calls.push(call); + return Promise.resolve(handler(call)); + }; + Object.assign(globalThis, { fetch: stub }); + return { calls }; +} + +export function jsonResponse(status: number, body: unknown): Response { + return Response.json(body, { status }); +} From aa9d8fc7f7d10f1b1355a4ee65cd3840a83c33ed Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 13:01:14 +0900 Subject: [PATCH 10/17] feat(client): add GraphQL query layer with stable app-owned DTO types Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/App/Api/GraphQL.purs | 440 +++++++++++++++++++++++++++++++++ src/App/Api/GraphQL/Types.purs | 205 +++++++++++++++ 2 files changed, 645 insertions(+) create mode 100644 src/App/Api/GraphQL.purs create mode 100644 src/App/Api/GraphQL/Types.purs diff --git a/src/App/Api/GraphQL.purs b/src/App/Api/GraphQL.purs new file mode 100644 index 0000000..e40eb70 --- /dev/null +++ b/src/App/Api/GraphQL.purs @@ -0,0 +1,440 @@ +module App.Api.GraphQL + ( fetchAccounts + , fetchAccountDetail + , createAccount + , updateProfile + , createMetadata + , updateMetadata + , deleteMetadata + ) where + +import Prelude + +import App.Api.Client (ApiError(..)) +import App.Api.GraphQL.Types (AccountResponse(..), MetadataResponse(..), ModerationResponse(..), ProfileResponse(..), Tristate(..)) +import Data.Array (any, intercalate, mapMaybe) +import Data.Argonaut.Core (Json) +import Data.Argonaut.Decode (decodeJson, printJsonDecodeError) +import Data.Argonaut.Decode.Error (JsonDecodeError) +import Data.Argonaut.Decode.Combinators ((.:)) +import Data.Either (Either(..), hush) +import Data.Maybe (Maybe(..), fromMaybe) +import Data.Newtype (unwrap) +import Effect.Aff (Aff, message, try) +import Foreign.Object as FO +import Generated.Schema.Gql (Mutation, Query) +import Generated.Schema.Gql.Enum.ModerationType (ModerationType(..)) +import GraphQL.Client.Args (Args(..)) +import GraphQL.Client.BaseClients.Affjax.Web (AffjaxWebClient(..)) +import GraphQL.Client.GqlError (GqlError) +import GraphQL.Client.ID (ID(..)) +import GraphQL.Client.Query (mutationJson, queryFullRes, queryJson) +import GraphQL.Client.Types (Client(..), GqlRes, GqlResJson(..)) +import Type.Data.List (Nil') +import Type.Proxy (Proxy) + +-- --------------------------------------------------------------------------- +-- GraphQL client +-- --------------------------------------------------------------------------- + +queryClient :: Client AffjaxWebClient { directives :: Proxy Nil', query :: Query | () } +queryClient = Client (AffjaxWebClient "/graphql" []) + +mutationClient :: Client AffjaxWebClient { directives :: Proxy Nil', mutation :: Mutation | () } +mutationClient = Client (AffjaxWebClient "/graphql" []) + +-- --------------------------------------------------------------------------- +-- Error helpers +-- --------------------------------------------------------------------------- + +-- | Check if a GqlError has a specific extensions.code. +hasExtensionCode :: String -> GqlError -> Boolean +hasExtensionCode target { extensions } = case extensions of + Just exts -> case FO.lookup "code" exts of + Just codeJson -> case decodeJson codeJson of + Right s -> s == target + _ -> false + Nothing -> false + Nothing -> false + +-- | Convert GqlRes (typed) to Either ApiError. +gqlResToEither :: forall a. GqlRes a -> Either ApiError a +gqlResToEither { data_, errors } = case errors of + Just errs -> + if any (hasExtensionCode "UNAUTHENTICATED") errs then + Left (HttpError 401 "Unauthorized") + else if any (hasExtensionCode "NOT_FOUND") errs then + Left (HttpError 404 "Not found") + else + Left (HttpError 500 (intercalate "; " (map _.message errs))) + Nothing -> case data_ of + Left decodeErr -> Left (DecodeError (printJsonDecodeError decodeErr)) + Right a -> Right a + +-- | Check a GraphQL response JSON for errors, then decode the data field +-- | using the provided decoder. +safeDecodeData :: forall a. Json -> (Json -> Either ApiError a) -> Either ApiError a +safeDecodeData json decodeData = + case decodeJson json of + Left e -> Left (DecodeError (printJsonDecodeError e)) + Right obj -> do + let errorsArr = hush (obj .: "errors") :: Maybe (Array Json) + case errorsArr of + Just errs -> + if anyErrorHasCode "UNAUTHENTICATED" errs then + Left (HttpError 401 "Unauthorized") + else if anyErrorHasCode "NOT_FOUND" errs then + Left (HttpError 404 "Not found") + else + Left (HttpError 500 (intercalate "; " (mapMaybe (\e -> hush (decodeJson e >>= (_ .: "message"))) errs))) + Nothing -> + case obj .: "data" of + Left e -> Left (DecodeError (printJsonDecodeError e)) + Right dataJson -> decodeData dataJson + +-- | Check if a JSON errors array contains an entry with a specific extensions.code. +anyErrorHasCode :: String -> Array Json -> Boolean +anyErrorHasCode target errs = + any (\e -> case decodeJson e of + Right obj -> case obj .: "extensions" of + Right exts -> case exts .: "code" of + Right code -> code == target + _ -> false + _ -> false + _ -> false + ) errs + +-- | Unwrap GraphQL ID to String. +unwrapId :: ID -> String +unwrapId = unwrap + +-- --------------------------------------------------------------------------- +-- Concrete result types (closed records for DecodeJson) +-- --------------------------------------------------------------------------- + +type GqlModData = + { type :: ModerationType + , reason :: String + , suspendedAt :: Maybe String + , expiresAt :: Maybe String + , bannedAt :: Maybe String + } + +type GqlProfileData = + { nanoid :: ID + , accountId :: ID + , displayName :: Maybe String + , summary :: Maybe String + , iconUrl :: Maybe String + , bannerUrl :: Maybe String + } + +type GqlMetaData = + { nanoid :: ID + , accountId :: ID + , label :: String + , content :: String + } + +type GqlAccountData = + { id :: ID + , name :: String + , isBot :: Boolean + , publicKey :: String + , createdAt :: String + , moderation :: Maybe GqlModData + , profile :: Maybe GqlProfileData + , metadata :: Array GqlMetaData + } + +type GqlAccountDetailData = { account :: Maybe GqlAccountData } +type GqlCreateAccountData = { createAccount :: GqlMinimalAccount } +type GqlUpdateProfileData = { updateProfile :: GqlProfileData } +type GqlCreateMetadataData = { createMetadata :: GqlMetaData } +type GqlUpdateMetadataData = { updateMetadata :: GqlMetaData } + +type GqlMinimalAccount = + { id :: ID + , name :: String + , isBot :: Boolean + , publicKey :: String + , createdAt :: String + } + +-- --------------------------------------------------------------------------- +-- fetchAccounts +-- --------------------------------------------------------------------------- + +fetchAccounts :: Aff (Either ApiError (Array AccountResponse)) +fetchAccounts = do + result <- try $ queryFullRes decodeJson identity queryClient "FetchAccounts" + { accounts: {} `Args` + { items: + { id: unit + , name: unit + , isBot: unit + , publicKey: unit + , createdAt: unit + } + , first: unit + , last: unit + } + } + pure $ case result of + Left affErr -> Left (NetworkError (message affErr)) + Right gqlRes -> case gqlResToEither gqlRes of + Right raw -> Right (map convertItem raw.accounts.items) + Left err -> Left err + where + convertItem item = + AccountResponse + { id: unwrapId item.id + , name: item.name + , isBot: item.isBot + , publicKey: item.publicKey + , createdAt: item.createdAt + , moderation: Nothing + } + +-- --------------------------------------------------------------------------- +-- fetchAccountDetail (account + profile + metadata, one query) +-- --------------------------------------------------------------------------- + +type AccountDetailResult = + { account :: AccountResponse + , profile :: Maybe ProfileResponse + , metadata :: Array MetadataResponse + } + +fetchAccountDetail :: String -> Aff (Either ApiError AccountDetailResult) +fetchAccountDetail accountId = do + result <- try $ queryJson identity queryClient "FetchAccountDetail" + { account: + { id: ID accountId } + `Args` + { id: unit + , name: unit + , isBot: unit + , publicKey: unit + , createdAt: unit + , moderation: + { type: unit + , reason: unit + , suspendedAt: unit + , expiresAt: unit + , bannedAt: unit + } + , profile: + { nanoid: unit + , accountId: unit + , displayName: unit + , summary: unit + , iconUrl: unit + , bannerUrl: unit + } + , metadata: + { nanoid: unit + , accountId: unit + , label: unit + , content: unit + } + } + } + pure $ case result of + Left affErr -> Left (NetworkError (message affErr)) + Right (GqlResJson json) -> safeDecodeData json \dataJson -> + case decodeJson dataJson :: Either JsonDecodeError GqlAccountDetailData of + Right detail -> case detail.account of + Just acc -> Right (convertDetail acc) + Nothing -> Left (HttpError 404 "Account not found") + Left e -> Left (DecodeError (printJsonDecodeError e)) + where + moderationFromRaw { type: SUSPENDED, reason, suspendedAt, expiresAt } = + Suspended { reason, suspendedAt: fromMaybe "" suspendedAt, expiresAt } + moderationFromRaw { type: BANNED, bannedAt, reason } = + Banned { reason, bannedAt: fromMaybe "" bannedAt } + convertDetail raw = + { account: AccountResponse + { id: unwrapId raw.id + , name: raw.name + , isBot: raw.isBot + , publicKey: raw.publicKey + , createdAt: raw.createdAt + , moderation: moderationFromRaw <$> raw.moderation + } + , profile: map convertProfile raw.profile + , metadata: map convertMetadata raw.metadata + } + convertProfile p = + ProfileResponse + { accountId: unwrapId p.accountId + , bannerUrl: p.bannerUrl + , displayName: p.displayName + , iconUrl: p.iconUrl + , nanoid: unwrapId p.nanoid + , summary: p.summary + } + convertMetadata m = + MetadataResponse + { accountId: unwrapId m.accountId + , content: m.content + , label: m.label + , nanoid: unwrapId m.nanoid + } + +-- --------------------------------------------------------------------------- +-- createAccount +-- --------------------------------------------------------------------------- + +createAccount :: String -> Boolean -> Aff (Either ApiError AccountResponse) +createAccount name isBot = do + result <- try $ mutationJson identity mutationClient "CreateAccount" + { createAccount: + { input: { name, isBot: isBot } } + `Args` + { id: unit + , name: unit + , isBot: unit + , publicKey: unit + , createdAt: unit + } + } + pure $ case result of + Left affErr -> Left (NetworkError (message affErr)) + Right (GqlResJson json) -> safeDecodeData json \dataJson -> + case decodeJson dataJson of + Right (raw :: { createAccount :: { id :: ID, name :: String, isBot :: Boolean, publicKey :: String, createdAt :: String } }) -> + Right $ AccountResponse + { id: unwrapId raw.createAccount.id + , name: raw.createAccount.name + , isBot: raw.createAccount.isBot + , publicKey: raw.createAccount.publicKey + , createdAt: raw.createAccount.createdAt + , moderation: Nothing + } + Left e -> Left (DecodeError (printJsonDecodeError e)) + +-- --------------------------------------------------------------------------- +-- updateProfile +-- --------------------------------------------------------------------------- + +updateProfile :: String -> { displayName :: Tristate String, summary :: Tristate String, iconUrl :: Tristate String, bannerUrl :: Tristate String } -> Aff (Either ApiError ProfileResponse) +updateProfile accountId fields = do + let + input = { displayName: tristateMaybe fields.displayName + , summary: tristateMaybe fields.summary + , iconUrl: tristateMaybe fields.iconUrl + , bannerUrl: tristateMaybe fields.bannerUrl + } + result <- try $ mutationJson identity mutationClient "UpdateProfile" + { updateProfile: + { accountId: ID accountId + , input: input + } + `Args` + { nanoid: unit + , accountId: unit + , displayName: unit + , summary: unit + , iconUrl: unit + , bannerUrl: unit + } + } + pure $ case result of + Left affErr -> Left (NetworkError (message affErr)) + Right (GqlResJson json) -> safeDecodeData json \dataJson -> + case decodeJson dataJson of + Right (raw :: { updateProfile :: { accountId :: ID, nanoid :: ID, displayName :: Maybe String, summary :: Maybe String, iconUrl :: Maybe String, bannerUrl :: Maybe String } }) -> + Right $ ProfileResponse + { accountId: unwrapId raw.updateProfile.accountId + , bannerUrl: raw.updateProfile.bannerUrl + , displayName: raw.updateProfile.displayName + , iconUrl: raw.updateProfile.iconUrl + , nanoid: unwrapId raw.updateProfile.nanoid + , summary: raw.updateProfile.summary + } + Left e -> Left (DecodeError (printJsonDecodeError e)) + where + tristateMaybe :: Tristate String -> Maybe String + tristateMaybe Omitted = Nothing + tristateMaybe SetNull = Nothing + tristateMaybe (Value s) = Just s + +-- --------------------------------------------------------------------------- +-- createMetadata +-- --------------------------------------------------------------------------- + +createMetadata :: String -> { label :: String, content :: String } -> Aff (Either ApiError MetadataResponse) +createMetadata accountId { label, content } = do + result <- try $ mutationJson identity mutationClient "CreateMetadata" + { createMetadata: + { accountId: ID accountId + , input: { label, content } + } + `Args` + { nanoid: unit + , accountId: unit + , label: unit + , content: unit + } + } + pure $ case result of + Left affErr -> Left (NetworkError (message affErr)) + Right (GqlResJson json) -> safeDecodeData json \dataJson -> + case decodeJson dataJson of + Right (raw :: { createMetadata :: { nanoid :: ID, accountId :: ID, label :: String, content :: String } }) -> + Right $ MetadataResponse + { accountId: unwrapId raw.createMetadata.accountId + , content: raw.createMetadata.content + , label: raw.createMetadata.label + , nanoid: unwrapId raw.createMetadata.nanoid + } + Left e -> Left (DecodeError (printJsonDecodeError e)) + +-- --------------------------------------------------------------------------- +-- updateMetadata +-- --------------------------------------------------------------------------- + +updateMetadata :: String -> String -> { label :: String, content :: String } -> Aff (Either ApiError MetadataResponse) +updateMetadata accountId nanoid { label, content } = do + result <- try $ mutationJson identity mutationClient "UpdateMetadata" + { updateMetadata: + { accountId: ID accountId + , nanoid: ID nanoid + , input: { label, content } + } + `Args` + { nanoid: unit + , accountId: unit + , label: unit + , content: unit + } + } + pure $ case result of + Left affErr -> Left (NetworkError (message affErr)) + Right (GqlResJson json) -> safeDecodeData json \dataJson -> + case decodeJson dataJson of + Right (raw :: { updateMetadata :: { nanoid :: ID, accountId :: ID, label :: String, content :: String } }) -> + Right $ MetadataResponse + { accountId: unwrapId raw.updateMetadata.accountId + , content: raw.updateMetadata.content + , label: raw.updateMetadata.label + , nanoid: unwrapId raw.updateMetadata.nanoid + } + Left e -> Left (DecodeError (printJsonDecodeError e)) + +-- --------------------------------------------------------------------------- +-- deleteMetadata +-- --------------------------------------------------------------------------- + +deleteMetadata :: String -> String -> Aff (Either ApiError Unit) +deleteMetadata accountId nanoid = do + result <- try $ mutationJson identity mutationClient "DeleteMetadata" + { deleteMetadata: + { accountId: ID accountId + , nanoid: ID nanoid + } `Args` unit + } + pure $ case result of + Left affErr -> Left (NetworkError (message affErr)) + Right (GqlResJson json) -> safeDecodeData json \_dataJson -> Right unit diff --git a/src/App/Api/GraphQL/Types.purs b/src/App/Api/GraphQL/Types.purs new file mode 100644 index 0000000..5359ce7 --- /dev/null +++ b/src/App/Api/GraphQL/Types.purs @@ -0,0 +1,205 @@ +module App.Api.GraphQL.Types where + +import Prelude + +import Data.Argonaut.Core (Json, isNull, jsonEmptyObject, jsonNull) +import Data.Argonaut.Decode (class DecodeJson, decodeJson, (.:), (.:?)) +import Data.Argonaut.Decode.Error (JsonDecodeError(..)) +import Data.Argonaut.Encode (class EncodeJson, encodeJson, (:=), (~>)) +import Data.Either (Either(..)) +import Data.Maybe (Maybe(..)) +import Foreign.Object as FO + +-- Response DTOs (same names/shapes as src/App/Api/Emumet/Types.purs) + +newtype AccountResponse = AccountResponse + { createdAt :: String + , id :: String + , isBot :: Boolean + , moderation :: (Maybe ModerationResponse) + , name :: String + , publicKey :: String + } + +instance EncodeJson AccountResponse where + encodeJson (AccountResponse r) = + "created_at" := r.createdAt + ~> "id" := r.id + ~> "is_bot" := r.isBot + ~> "moderation" := r.moderation + ~> "name" := r.name + ~> "public_key" := r.publicKey + ~> jsonEmptyObject + +instance DecodeJson AccountResponse where + decodeJson json = do + obj <- decodeJson json + createdAt <- obj .: "created_at" + id <- obj .: "id" + isBot <- obj .: "is_bot" + moderation <- join <$> obj .:? "moderation" + name <- obj .: "name" + publicKey <- obj .: "public_key" + pure (AccountResponse { createdAt, id, isBot, moderation, name, publicKey }) + +newtype AccountsResponse = AccountsResponse + { first :: (Maybe String) + , items :: (Array AccountResponse) + , last :: (Maybe String) + } + +instance EncodeJson AccountsResponse where + encodeJson (AccountsResponse r) = + "first" := r.first + ~> "items" := r.items + ~> "last" := r.last + ~> jsonEmptyObject + +instance DecodeJson AccountsResponse where + decodeJson json = do + obj <- decodeJson json + first <- join <$> obj .:? "first" + items <- obj .: "items" + last <- join <$> obj .:? "last" + pure (AccountsResponse { first, items, last }) + +newtype ProfileResponse = ProfileResponse + { accountId :: String + , bannerUrl :: (Maybe String) + , displayName :: (Maybe String) + , iconUrl :: (Maybe String) + , nanoid :: String + , summary :: (Maybe String) + } + +instance EncodeJson ProfileResponse where + encodeJson (ProfileResponse r) = + "account_id" := r.accountId + ~> "banner_url" := r.bannerUrl + ~> "display_name" := r.displayName + ~> "icon_url" := r.iconUrl + ~> "nanoid" := r.nanoid + ~> "summary" := r.summary + ~> jsonEmptyObject + +instance DecodeJson ProfileResponse where + decodeJson json = do + obj <- decodeJson json + accountId <- obj .: "account_id" + bannerUrl <- join <$> obj .:? "banner_url" + displayName <- join <$> obj .:? "display_name" + iconUrl <- join <$> obj .:? "icon_url" + nanoid <- obj .: "nanoid" + summary <- join <$> obj .:? "summary" + pure (ProfileResponse { accountId, bannerUrl, displayName, iconUrl, nanoid, summary }) + +newtype MetadataResponse = MetadataResponse + { accountId :: String + , content :: String + , label :: String + , nanoid :: String + } + +instance EncodeJson MetadataResponse where + encodeJson (MetadataResponse r) = + "account_id" := r.accountId + ~> "content" := r.content + ~> "label" := r.label + ~> "nanoid" := r.nanoid + ~> jsonEmptyObject + +instance DecodeJson MetadataResponse where + decodeJson json = do + obj <- decodeJson json + accountId <- obj .: "account_id" + content <- obj .: "content" + label <- obj .: "label" + nanoid <- obj .: "nanoid" + pure (MetadataResponse { accountId, content, label, nanoid }) + +-- ModerationResponse ADT +-- BFF flat Moderation type conversion: +-- type=SUSPENDED → Suspended { reason, suspendedAt, expiresAt } +-- type=BANNED → Banned { reason, bannedAt } +data ModerationResponse + = Suspended + { expiresAt :: (Maybe String) + , reason :: String + , suspendedAt :: String + } + | Banned + { bannedAt :: String + , reason :: String + } + +instance EncodeJson ModerationResponse where + encodeJson (Suspended r) = + "type" := "suspended" + ~> "expires_at" := r.expiresAt + ~> "reason" := r.reason + ~> "suspended_at" := r.suspendedAt + ~> jsonEmptyObject + encodeJson (Banned r) = + "type" := "banned" + ~> "banned_at" := r.bannedAt + ~> "reason" := r.reason + ~> jsonEmptyObject + +instance DecodeJson ModerationResponse where + decodeJson json = do + obj <- decodeJson json + tag <- obj .: "type" + case (tag :: String) of + "suspended" -> do + expiresAt <- join <$> obj .:? "expires_at" + reason <- obj .: "reason" + suspendedAt <- obj .: "suspended_at" + pure (Suspended { expiresAt, reason, suspendedAt }) + "banned" -> do + bannedAt <- obj .: "banned_at" + reason <- obj .: "reason" + pure (Banned { bannedAt, reason }) + other -> Left (UnexpectedValue (encodeJson other)) + +-- Tristate ADT for optional+nullable input fields +-- Omitted → field absent (no change) +-- SetNull → explicit null (clear value) +-- Value a → field has a value +data Tristate a = Omitted | SetNull | Value a + +derive instance eqTristate :: Eq a => Eq (Tristate a) + +instance showTristate :: Show a => Show (Tristate a) where + show Omitted = "Omitted" + show SetNull = "SetNull" + show (Value a) = "(Value " <> show a <> ")" + +instance encodeJsonTristate :: EncodeJson a => EncodeJson (Tristate a) where + encodeJson Omitted = jsonNull + encodeJson SetNull = jsonNull + encodeJson (Value a) = encodeJson a + +instance decodeJsonTristate :: DecodeJson a => DecodeJson (Tristate a) where + decodeJson json = do + (m :: Maybe a) <- decodeJson json + pure case m of + Nothing -> SetNull + Just a -> Value a + +-- | Insert a Tristate field into a Foreign.Object builder. +-- | Omitted → skip, SetNull → insert null, Value → insert encoded. +tristateField :: forall a. EncodeJson a => String -> Tristate a -> FO.Object Json -> FO.Object Json +tristateField _ Omitted obj = obj +tristateField key SetNull obj = FO.insert key jsonNull obj +tristateField key (Value a) obj = FO.insert key (encodeJson a) obj + +-- | Decode a Tristate field from a decoded JSON object. +-- | Key absent → Omitted, key present+null → SetNull, key present+value → Value. +tristateDecodeField :: forall a. DecodeJson a => FO.Object Json -> String -> Either JsonDecodeError (Tristate a) +tristateDecodeField obj key = case FO.lookup key obj of + Nothing -> Right Omitted + Just json + | isNull json -> Right SetNull + | otherwise -> case decodeJson json of + Left err -> Left err + Right a -> Right (Value a) From f4160ff8be3f440fc4dd20db333f4fd932923a64 Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 13:25:59 +0900 Subject: [PATCH 11/17] refactor(client): migrate data fetching to GraphQL and drop stale-refresh machinery --- src/App/Message.purs | 6 +- src/App/Model.purs | 4 +- src/App/View/AccountDetail.purs | 20 +---- src/App/View/Accounts.purs | 2 +- src/Client/Update.purs | 145 +++++++------------------------- 5 files changed, 37 insertions(+), 140 deletions(-) diff --git a/src/App/Message.purs b/src/App/Message.purs index 82616e6..2115098 100644 --- a/src/App/Message.purs +++ b/src/App/Message.purs @@ -1,6 +1,6 @@ module App.Message where -import App.Api.Emumet.Types (AccountResponse, MetadataResponse, ProfileResponse) +import App.Api.GraphQL.Types (AccountResponse, MetadataResponse, ProfileResponse) import App.Model (AccountWithDetails, SessionInfo) import App.Route (Route) import Data.Maybe (Maybe) @@ -25,7 +25,7 @@ data Message | FetchAccounts | AccountsLoaded (Array AccountResponse) | AccountsFailed String - -- Account detail (fetch account + profile + metadata in parallel) + -- Account detail (account + profile + metadata via one GraphQL query) | FetchAccountDetail String | AccountDetailLoaded String AccountWithDetails | AccountDetailFailed String String @@ -43,7 +43,6 @@ data Message | SetEditProfileBannerUrl String | SaveProfile | ProfileSaved Int String ProfileResponse -- generation, accountId, profile - | ProfileSavedRefreshFailed Int String String -- generation, accountId, refresh error (save succeeded, re-fetch failed) | ProfileSaveFailed Int String String -- generation, accountId, errorMsg | CancelEditProfile -- Metadata editing @@ -53,7 +52,6 @@ data Message | SetMetadataContent String | SaveMetadata | MetadataSaved Int String MetadataResponse -- generation, accountId, metadata - | MetadataSavedRefreshFailed Int String String -- generation, accountId, refresh error (save succeeded, re-fetch failed) | MetadataSaveFailed Int String String -- generation, accountId, errorMsg | CancelMetadata | DeleteMetadata String diff --git a/src/App/Model.purs b/src/App/Model.purs index dad25c6..b8b2632 100644 --- a/src/App/Model.purs +++ b/src/App/Model.purs @@ -2,7 +2,7 @@ module App.Model where import Prelude -import App.Api.Emumet.Types (AccountResponse, MetadataResponse, ProfileResponse) +import App.Api.GraphQL.Types (AccountResponse, MetadataResponse, ProfileResponse) import App.Route (Route) import App.Route as Route import Data.Argonaut.Decode (class DecodeJson) @@ -37,8 +37,6 @@ type AccountWithDetails = { account :: AccountResponse , profile :: Maybe ProfileResponse , metadata :: Array MetadataResponse - , profileStale :: Boolean - , metadataStale :: Boolean } -- Session info from BFF (GET /auth/session) diff --git a/src/App/View/AccountDetail.purs b/src/App/View/AccountDetail.purs index 0ccd157..608adf3 100644 --- a/src/App/View/AccountDetail.purs +++ b/src/App/View/AccountDetail.purs @@ -2,7 +2,7 @@ module App.View.AccountDetail where import Prelude -import App.Api.Emumet.Types (AccountResponse(..), MetadataResponse(..), ProfileResponse(..)) +import App.Api.GraphQL.Types (AccountResponse(..), MetadataResponse(..), ProfileResponse(..)) import App.Format (formatDate) import App.Message (Message(..)) import App.Model (AccountWithDetails, EditMetadataForm, EditProfileForm, RemoteData(..)) @@ -41,7 +41,6 @@ detailView d editProfile editMetadata errorMsg savePending = [ bannerSection d.profile , profileHeader acc d.profile , errorBanner errorMsg - , staleBanner d.profileStale d.metadataStale , case editProfile of Just form -> editProfileSection form savePending Nothing -> profileSection d.profile @@ -49,23 +48,6 @@ detailView d editProfile editMetadata errorMsg savePending = , accountInfoSection acc ] --- Persistent banner shown when the on-screen profile/metadata may be stale --- because the post-save re-fetch failed. Disappears only on a successful refresh. -staleBanner :: Boolean -> Boolean -> Html Message -staleBanner profileStale metadataStale = - case profileStale, metadataStale of - false, false -> HE.div [ HA.class' "hidden" ] [] - _, _ -> - let - what = case profileStale, metadataStale of - true, true -> "profile and metadata" - true, false -> "profile" - false, true -> "metadata" - false, false -> "" - in - HE.div [ HA.class' ("px-4 py-3 text-sm " <> T.roundedTheme <> " border " <> T.borderTheme <> " bg-yellow-500/10 " <> T.textPrimary) ] - [ HE.text ("Saved, but failed to refresh the latest " <> what <> ". The values shown below may be out of date until you reload.") ] - errorBanner :: Maybe String -> Html Message errorBanner = case _ of Nothing -> HE.div [ HA.class' "hidden" ] [] diff --git a/src/App/View/Accounts.purs b/src/App/View/Accounts.purs index 4bdf3ff..50f9a09 100644 --- a/src/App/View/Accounts.purs +++ b/src/App/View/Accounts.purs @@ -2,7 +2,7 @@ module App.View.Accounts where import Prelude -import App.Api.Emumet.Types (AccountResponse(..)) +import App.Api.GraphQL.Types (AccountResponse(..)) import App.Message (Message) import App.Model (RemoteData(..)) import App.Route as Route diff --git a/src/Client/Update.purs b/src/Client/Update.purs index 5fed9d5..9437d20 100644 --- a/src/Client/Update.purs +++ b/src/Client/Update.purs @@ -5,13 +5,11 @@ import Prelude import App.Api.Client (ApiError(..), printApiError) import App.Api.Auth as Auth import App.Api.Auth (LoginRequest(..), LoginResponse(..), SessionResponse(..)) -import App.Api.Emumet.Client as Emumet -import App.Api.Emumet.Tristate (Tristate(..)) -import App.Api.Emumet.Types (AccountResponse(..), CreateAccountRequest(..), CreateMetadataRequest(..), MetadataResponse(..), ProfileResponse(..), UpdateMetadataRequest(..), UpdateProfileRequest(..)) +import App.Api.GraphQL as GraphQL +import App.Api.GraphQL.Types (AccountResponse(..), MetadataResponse(..), ProfileResponse(..), Tristate(..)) import App.Message (Message(..)) import App.Model (AccountWithDetails, Model, RemoteData(..), emptyLoginForm, emptyNewAccountForm, isProtectedRoute, pageForMaybeRoute) import App.Route (Route(..), routeCodec) -import Control.Parallel (parallel, sequential) import Data.Array (filter, find) import Data.Either (Either(..)) import Data.Maybe (Maybe(..), fromMaybe, isJust, isNothing) @@ -185,7 +183,7 @@ mkUpdate nav model = case _ of if model.route == Just Home then noMessages $ model { accounts = Failed, errorMessage = Just msg } else noMessages model - -- Account detail: fetch account + profile + metadata (parallel) + -- Account detail: fetch account + profile + metadata (single GraphQL query) FetchAccountDetail id -> Tuple (model { selectedAccount = Loading }) [ fetchAccountDetailAff id ] @@ -302,7 +300,7 @@ mkUpdate nav model = case _ of else if currentAccountId model == Just accountId then case model.selectedAccount of Loaded d -> noMessages $ model - { selectedAccount = Loaded (d { profile = Just profile, profileStale = false }) + { selectedAccount = Loaded (d { profile = Just profile }) , editProfileForm = Nothing , errorMessage = Nothing , savePending = false @@ -315,21 +313,6 @@ mkUpdate nav model = case _ of else if currentAccountId model == Just accountId then noMessages $ model { errorMessage = Just msg, savePending = false } else noMessages $ model { savePending = false } - ProfileSavedRefreshFailed gen accountId _refreshErr -> - -- Save succeeded; only the follow-up re-fetch failed. Mark the profile as stale - -- so the View renders a persistent banner until the user gets a fresh state. - if gen /= model.saveGeneration then noMessages model - else if currentAccountId model == Just accountId then case model.selectedAccount of - Loaded d -> - noMessages $ model - { selectedAccount = Loaded (d { profileStale = true }) - , editProfileForm = Nothing - , savePending = false - , errorMessage = Nothing - } - _ -> noMessages $ model { editProfileForm = Nothing, savePending = false } - else noMessages $ model { savePending = false } - CancelEditProfile -> noMessages $ model { editProfileForm = Nothing, errorMessage = Nothing } @@ -388,7 +371,7 @@ mkUpdate nav model = case _ of Nothing -> d.metadata <> [ meta ] in noMessages $ model - { selectedAccount = Loaded (d { metadata = newMetadata, metadataStale = false }) + { selectedAccount = Loaded (d { metadata = newMetadata }) , editMetadataForm = Nothing , errorMessage = Nothing , savePending = false @@ -401,21 +384,6 @@ mkUpdate nav model = case _ of else if currentAccountId model == Just accountId then noMessages $ model { errorMessage = Just msg, savePending = false } else noMessages $ model { savePending = false } - MetadataSavedRefreshFailed gen accountId _refreshErr -> - -- Save succeeded; only the follow-up re-fetch failed (or returned list missing the item). - -- Mark metadata as stale so the View renders a persistent banner. - if gen /= model.saveGeneration then noMessages model - else if currentAccountId model == Just accountId then case model.selectedAccount of - Loaded d -> - noMessages $ model - { selectedAccount = Loaded (d { metadataStale = true }) - , editMetadataForm = Nothing - , savePending = false - , errorMessage = Nothing - } - _ -> noMessages $ model { editMetadataForm = Nothing, savePending = false } - else noMessages $ model { savePending = false } - CancelMetadata -> noMessages $ model { editMetadataForm = Nothing, errorMessage = Nothing } @@ -452,11 +420,6 @@ mkUpdate nav model = case _ of findMetadata :: String -> Array MetadataResponse -> Maybe MetadataResponse findMetadata targetId = find (\(MetadataResponse r) -> r.nanoid == targetId) --- | Check if an ApiError is a 404 (resource not found = not yet created) -is404 :: ApiError -> Boolean -is404 (HttpError 404 _) = true -is404 _ = false - -- | Check if an ApiError is a 401 (session expired / unauthorized) isUnauthorized :: ApiError -> Boolean isUnauthorized (HttpError 401 _) = true @@ -475,7 +438,7 @@ checkSessionAff = do fetchAccountsAff :: Aff (Maybe Message) fetchAccountsAff = do - result <- Emumet.fetchAccounts + result <- GraphQL.fetchAccounts pure $ Just $ case result of Right accs -> AccountsLoaded accs Left err | isUnauthorized err -> SessionExpired @@ -483,41 +446,20 @@ fetchAccountsAff = do fetchAccountDetailAff :: String -> Aff (Maybe Message) fetchAccountDetailAff id = do - accResult <- Emumet.fetchAccount id - case accResult of - Left err | isUnauthorized err -> pure $ Just SessionExpired - Left err -> pure $ Just $ AccountDetailFailed id (printApiError err) - Right acc -> do - let AccountResponse a = acc - -- Fetch profile and metadata in parallel - Tuple profileResult metadataResult <- sequential $ - Tuple <$> parallel (Emumet.fetchProfile a.id) <*> parallel (Emumet.fetchMetadata a.id) - -- Check for 401 in parallel results first (session expired) - case profileResult, metadataResult of - Left err, _ | isUnauthorized err -> pure $ Just SessionExpired - _, Left err | isUnauthorized err -> pure $ Just SessionExpired - _, _ -> do - -- 404 = not yet created (normal), other errors = real failure - let - profileOutcome = case profileResult of - Right p -> Right (Just p) - Left err -> if is404 err then Right Nothing else Left err - metadataOutcome = case metadataResult of - Right ms -> Right ms - Left err -> if is404 err then Right [] else Left err - case profileOutcome, metadataOutcome of - Left err, _ -> pure $ Just $ AccountDetailFailed id ("Failed to load profile: " <> printApiError err) - _, Left err -> pure $ Just $ AccountDetailFailed id ("Failed to load metadata: " <> printApiError err) - Right profile, Right metadata -> - let - detail :: AccountWithDetails - detail = { account: acc, profile, metadata, profileStale: false, metadataStale: false } - in - pure $ Just $ AccountDetailLoaded id detail + result <- GraphQL.fetchAccountDetail id + pure $ Just $ case result of + Left err | isUnauthorized err -> SessionExpired + Left err -> AccountDetailFailed id (printApiError err) + Right r -> + let + detail :: AccountWithDetails + detail = { account: r.account, profile: r.profile, metadata: r.metadata } + in + AccountDetailLoaded id detail submitNewAccountAff :: String -> Boolean -> Aff (Maybe Message) submitNewAccountAff name isBot = do - result <- Emumet.createAccount (CreateAccountRequest { name, isBot }) + result <- GraphQL.createAccount name isBot pure $ Just $ case result of Right acc -> AccountCreated acc Left err | isUnauthorized err -> SessionExpired @@ -525,50 +467,27 @@ submitNewAccountAff name isBot = do saveProfileAff :: Int -> String -> { displayName :: Tristate String, summary :: Tristate String, iconUrl :: Tristate String, bannerUrl :: Tristate String } -> Aff (Maybe Message) saveProfileAff gen accountId fields = do - result <- Emumet.updateProfile accountId (UpdateProfileRequest fields) - case result of - Left err | isUnauthorized err -> pure $ Just SessionExpired - Left err -> pure $ Just $ ProfileSaveFailed gen accountId (printApiError err) - Right _ -> do - -- PUT returns 204 No Content; re-fetch to get the updated profile. - -- Save itself succeeded, so a re-fetch failure must NOT be reported as a save failure. - fetched <- Emumet.fetchProfile accountId - pure $ Just $ case fetched of - Right profile -> ProfileSaved gen accountId profile - Left err | isUnauthorized err -> SessionExpired - Left err -> ProfileSavedRefreshFailed gen accountId (printApiError err) + -- Mutation returns the updated profile directly; no re-fetch needed. + result <- GraphQL.updateProfile accountId fields + pure $ Just $ case result of + Right profile -> ProfileSaved gen accountId profile + Left err | isUnauthorized err -> SessionExpired + Left err -> ProfileSaveFailed gen accountId (printApiError err) saveMetadataAff :: Int -> String -> Maybe String -> { label :: String, content :: String } -> Aff (Maybe Message) saveMetadataAff gen accountId mNanoid fields = do - case mNanoid of - Just nanoid -> do - -- Update path: PUT returns 204, re-fetch metadata list and pick this entry. - -- Save itself succeeded, so a re-fetch failure (or item-not-found in the refreshed list) - -- must NOT be reported as a save failure. - result <- Emumet.updateMetadata accountId nanoid (UpdateMetadataRequest fields) - case result of - Left err | isUnauthorized err -> pure $ Just SessionExpired - Left err -> pure $ Just $ MetadataSaveFailed gen accountId (printApiError err) - Right _ -> do - fetched <- Emumet.fetchMetadata accountId - pure $ Just $ case fetched of - Left err | isUnauthorized err -> SessionExpired - Left err -> MetadataSavedRefreshFailed gen accountId (printApiError err) - Right metas -> - case findMetadata nanoid metas of - Just meta -> MetadataSaved gen accountId meta - Nothing -> MetadataSavedRefreshFailed gen accountId "Saved, but the updated item was not found in the refreshed list" - Nothing -> do - -- Create path: POST returns 201 + body - result <- Emumet.createMetadata accountId (CreateMetadataRequest fields) - pure $ Just $ case result of - Right meta -> MetadataSaved gen accountId meta - Left err | isUnauthorized err -> SessionExpired - Left err -> MetadataSaveFailed gen accountId (printApiError err) + -- Mutations return the saved metadata directly; no re-fetch needed. + result <- case mNanoid of + Just nanoid -> GraphQL.updateMetadata accountId nanoid fields + Nothing -> GraphQL.createMetadata accountId fields + pure $ Just $ case result of + Right meta -> MetadataSaved gen accountId meta + Left err | isUnauthorized err -> SessionExpired + Left err -> MetadataSaveFailed gen accountId (printApiError err) deleteMetadataAff :: Int -> String -> String -> Aff (Maybe Message) deleteMetadataAff gen accountId nanoid = do - result <- Emumet.deleteMetadata accountId nanoid + result <- GraphQL.deleteMetadata accountId nanoid pure $ Just $ case result of Right _ -> MetadataDeleted gen accountId nanoid Left err | isUnauthorized err -> SessionExpired From 78c466dbfbf41bcc11bd78c36ac1cdae28807f1b Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 13:32:19 +0900 Subject: [PATCH 12/17] fix(deps): use graphql v16 to satisfy @urql/core subpath imports in client bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit graphql v17's exports map appends .mjs to subpaths like graphql/language/visitor.mjs, producing visitor.mjs.mjs which esbuild cannot resolve. @urql/core (transitive dep of purescript-graphql-client) imports these subpaths, breaking the browser bundle. v16 has real .mjs files at the expected paths. This unblocks spago bundle --platform browser, the dev server, and all frontend development. - graphql: ^17.0.2 → ^16 (resolves v16.14.2) - No other dependencies changed - Generated graphql types (src/Generated/) are identical under v16 introspection - T8's DIRECTIVE_DEFINITION filter in sync-graphql.ts is a harmless no-op under v16 --- bun.lock | 6 ++---- package.json | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index 4574f9d..c3d431a 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "dependencies": { "@graphql-tools/schema": "^10.0.38", "dataloader": "^2.2.3", - "graphql": "^17.0.2", + "graphql": "^16", "graphql-yoga": "^5.21.2", }, "devDependencies": { @@ -220,7 +220,7 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "graphql": ["graphql@17.0.2", "", {}, "sha512-FRWbddMxfkjiB7z+aQDWIR+E34xo9I8c9mtK2RPv8PmMzKRvrdsreHL/Ui/TmwHJfhHChEtsFPyMHKI+xuarQQ=="], + "graphql": ["graphql@16.14.2", "", {}, "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA=="], "graphql-ws": ["graphql-ws@5.16.2", "", { "peerDependencies": { "graphql": ">=0.11 <=16" } }, "sha512-E1uccsZxt/96jH/OwmLPuXMACILs76pKF2i3W861LpKBCYtGIyPQGtWLuBLkND4ox1KHns70e83PS4te50nvPQ=="], @@ -407,7 +407,5 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], - - "purescript-graphql-client/graphql": ["graphql@16.14.2", "", {}, "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA=="], } } diff --git a/package.json b/package.json index 7c694c1..e102774 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dependencies": { "@graphql-tools/schema": "^10.0.38", "dataloader": "^2.2.3", - "graphql": "^17.0.2", + "graphql": "^16", "graphql-yoga": "^5.21.2" } } From fa03bfd7f7679d2b8ffb4b0570e21aed6a3c46f1 Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 13:44:27 +0900 Subject: [PATCH 13/17] chore: remove legacy REST proxy, mock REST API, and OpenAPI codegen pipeline --- index.ts | 290 -------- openapi/emumet.json | 1179 ------------------------------ scripts/fetch-openapi.ts | 85 --- scripts/generate-api.ts | 509 ------------- scripts/sync-api.ts | 99 --- src/App/Api/Emumet/Client.purs | 68 -- src/App/Api/Emumet/Tristate.purs | 55 -- src/App/Api/Emumet/Types.purs | 367 ---------- src/App/Api/GraphQL/Types.purs | 2 +- 9 files changed, 1 insertion(+), 2653 deletions(-) delete mode 100644 openapi/emumet.json delete mode 100644 scripts/fetch-openapi.ts delete mode 100644 scripts/generate-api.ts delete mode 100644 scripts/sync-api.ts delete mode 100644 src/App/Api/Emumet/Client.purs delete mode 100644 src/App/Api/Emumet/Tristate.purs delete mode 100644 src/App/Api/Emumet/Types.purs diff --git a/index.ts b/index.ts index 0a04d9b..0e6d8c8 100644 --- a/index.ts +++ b/index.ts @@ -170,58 +170,6 @@ function serveStatic(pathname: string): Response | null { }); } -// ============================================================ -// Mock data store -// ============================================================ - -interface MockAccount { - id: string; - name: string; - is_bot: boolean; - public_key: string; - created_at: string; - moderation: null; -} - -interface MockProfile { - account_id: string; - nanoid: string; - display_name: string | null; - summary: string | null; - icon_url: string | null; - banner_url: string | null; -} - -interface MockMetadata { - account_id: string; - nanoid: string; - label: string; - content: string; -} - -let mockNextId = 100; -function nextId(): string { - return String(mockNextId++); -} - -const mockAccounts: MockAccount[] = [ - { id: "acc_01", name: "alice", is_bot: false, public_key: "ed25519:AAAA", created_at: "2025-01-15T09:00:00Z", moderation: null }, - { id: "acc_02", name: "bob", is_bot: false, public_key: "ed25519:BBBB", created_at: "2025-02-20T14:30:00Z", moderation: null }, - { id: "acc_03", name: "bot-news", is_bot: true, public_key: "ed25519:CCCC", created_at: "2025-03-10T00:00:00Z", moderation: null }, -]; - -const mockProfiles: MockProfile[] = [ - { account_id: "acc_01", nanoid: "prof_01", display_name: "Alice Wonderland", summary: "Exploring the rabbit hole of federated social networks.", icon_url: "https://api.dicebear.com/9.x/thumbs/svg?seed=alice", banner_url: "https://picsum.photos/seed/alice/800/200" }, - { account_id: "acc_02", nanoid: "prof_02", display_name: "Bob Builder", summary: "Can we fix it? Yes we can!", icon_url: "https://api.dicebear.com/9.x/thumbs/svg?seed=bob", banner_url: null }, - { account_id: "acc_03", nanoid: "prof_03", display_name: "News Bot", summary: "Automated news aggregator.", icon_url: "https://api.dicebear.com/9.x/thumbs/svg?seed=bot", banner_url: null }, -]; - -const mockMetadata: MockMetadata[] = [ - { account_id: "acc_01", nanoid: "meta_01", label: "Website", content: "https://alice.example.com" }, - { account_id: "acc_01", nanoid: "meta_02", label: "Pronouns", content: "she/her" }, - { account_id: "acc_02", nanoid: "meta_03", label: "GitHub", content: "https://github.com/bob" }, -]; - // ============================================================ // Mock Auth handlers (BFF /auth/* endpoints) // ============================================================ @@ -664,235 +612,6 @@ async function handleRealAuth(req: Request, pathname: string): Promise { - // Enforce session authentication - const session = getMockSession(req); - if (!session) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const method = req.method; - - // GET /api/accounts - if (method === "GET" && pathname === "/api/accounts") { - return Response.json({ items: mockAccounts, first: null, last: null }); - } - - // POST /api/accounts - if (method === "POST" && pathname === "/api/accounts") { - const data = await req.json() as { name: string; is_bot?: boolean }; - const id = "acc_" + nextId(); - const acc: MockAccount = { - id, - name: data.name, - is_bot: data.is_bot ?? false, - public_key: "ed25519:MOCK_" + id, - created_at: new Date().toISOString(), - moderation: null, - }; - mockAccounts.push(acc); - return Response.json(acc, { status: 201 }); - } - - // GET /api/accounts/:id - const accountMatch = pathname.match(/^\/api\/accounts\/([^/]+)$/); - if (method === "GET" && accountMatch) { - const accId = accountMatch[1]!; - const acc = mockAccounts.find(a => a.id === accId); - return acc ? Response.json(acc) : Response.json({ error: "not found" }, { status: 404 }); - } - - // Profile routes: /api/accounts/:id/profile - const profileMatch = pathname.match(/^\/api\/accounts\/([^/]+)\/profile$/); - if (profileMatch) { - const accountId = profileMatch[1]!; - - if (method === "GET") { - const profile = mockProfiles.find(p => p.account_id === accountId); - return profile ? Response.json(profile) : Response.json({ error: "not found" }, { status: 404 }); - } - - if (method === "POST") { - const data = await req.json() as Record; - const existingIdx = mockProfiles.findIndex(p => p.account_id === accountId); - const profile: MockProfile = { - account_id: accountId, - nanoid: existingIdx >= 0 ? mockProfiles[existingIdx]!.nanoid : "prof_" + nextId(), - display_name: resolveTristateField(data, "display_name"), - summary: resolveTristateField(data, "summary"), - icon_url: resolveTristateField(data, "icon_url"), - banner_url: resolveTristateField(data, "banner_url"), - }; - if (existingIdx >= 0) { - mockProfiles[existingIdx] = profile; - } else { - mockProfiles.push(profile); - } - return Response.json(profile, { status: 201 }); - } - - if (method === "PUT") { - const data = await req.json() as Record; - const idx = mockProfiles.findIndex(p => p.account_id === accountId); - if (idx < 0) return Response.json({ error: "not found" }, { status: 404 }); - const existing = mockProfiles[idx]!; - mockProfiles[idx] = { - account_id: existing.account_id, - nanoid: existing.nanoid, - display_name: resolveTristateUpdate(data, "display_name", existing.display_name), - summary: resolveTristateUpdate(data, "summary", existing.summary), - icon_url: resolveTristateUpdate(data, "icon_url", existing.icon_url), - banner_url: resolveTristateUpdate(data, "banner_url", existing.banner_url), - }; - return Response.json(mockProfiles[idx]); - } - } - - // GET /api/metadata?account_ids=... - if (method === "GET" && pathname.startsWith("/api/metadata")) { - const url = new URL(req.url); - const accountIds = url.searchParams.get("account_ids")?.split(",") ?? []; - const metas = mockMetadata.filter(m => accountIds.includes(m.account_id)); - return Response.json(metas); - } - - // Metadata routes with nanoid: /api/accounts/:id/metadata/:nanoid - const metadataWithIdMatch = pathname.match(/^\/api\/accounts\/([^/]+)\/metadata\/([^/]+)$/); - if (metadataWithIdMatch) { - const accountId = metadataWithIdMatch[1]!; - const nanoid = metadataWithIdMatch[2]!; - - if (method === "PUT") { - const data = await req.json() as { label: string; content: string }; - const idx = mockMetadata.findIndex(m => m.account_id === accountId && m.nanoid === nanoid); - if (idx < 0) return Response.json({ error: "not found" }, { status: 404 }); - mockMetadata[idx] = { account_id: accountId, nanoid, label: data.label, content: data.content }; - return Response.json(mockMetadata[idx]); - } - - if (method === "DELETE") { - const idx = mockMetadata.findIndex(m => m.account_id === accountId && m.nanoid === nanoid); - if (idx < 0) return Response.json({ error: "not found" }, { status: 404 }); - mockMetadata.splice(idx, 1); - return new Response(null, { status: 204 }); - } - } - - // POST /api/accounts/:id/metadata - const metadataPostMatch = pathname.match(/^\/api\/accounts\/([^/]+)\/metadata$/); - if (method === "POST" && metadataPostMatch) { - const accountId = metadataPostMatch[1]!; - const data = await req.json() as { label: string; content: string }; - const meta: MockMetadata = { - account_id: accountId, - nanoid: "meta_" + nextId(), - label: data.label, - content: data.content, - }; - mockMetadata.push(meta); - return Response.json(meta, { status: 201 }); - } - - return Response.json({ error: "not found" }, { status: 404 }); -} - -// Tristate JSON handling -function resolveTristateField(data: Record, key: string): string | null { - if (!(key in data)) return null; - return data[key] as string | null; -} - -function resolveTristateUpdate(data: Record, key: string, existing: string | null): string | null { - if (!(key in data)) return existing; - return data[key] as string | null; -} - -// ============================================================ -// Real API proxy (with lazy token refresh) -// ============================================================ - -const PROXY_ALLOWED_HEADERS = [ - "content-type", - "accept", - "content-length", -]; - -async function proxyToEmumet(req: Request, pathname: string): Promise { - const apiPath = pathname.replace(/^\/api/, ""); - const targetUrl = EMUMET_API_URL + apiPath + new URL(req.url).search; - - const headers = new Headers(); - for (const key of PROXY_ALLOWED_HEADERS) { - const value = req.headers.get(key); - if (value) headers.set(key, value); - } - - // Read and optionally refresh session — reject unauthenticated requests - let session = await getRealSession(req); - if (!session) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - let updatedSessionHeaders: Headers | null = null; - - if (isSessionExpiringSoon(session)) { - const refreshed = await refreshAccessToken(session); - if (refreshed) { - session = refreshed; - updatedSessionHeaders = new Headers(); - await setRealSessionCookie(updatedSessionHeaders, refreshed); - } else if (session.expiresAt <= Math.floor(Date.now() / 1000)) { - // Fully expired, no refresh possible - const h = new Headers({ "Content-Type": "application/json" }); - h.append("Set-Cookie", clearCookieHeader(SESSION_COOKIE_NAME)); - return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: h }); - } - } - - // Inject Bearer token (NEVER forward browser's Authorization header) - headers.set("Authorization", "Bearer " + session.accessToken); - // DEBUG: Log JWT claims for troubleshooting - try { - const payload = session.accessToken.split(".")[1]!; - const claims = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(payload.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)))); - console.log("[DEBUG proxy] JWT claims:", JSON.stringify({ iss: claims.iss, sub: claims.sub, aud: claims.aud, exp: claims.exp, scp: claims.scp })); - console.log("[DEBUG proxy] Target URL:", targetUrl); - } catch (e) { console.log("[DEBUG proxy] JWT decode error:", e); } - - const proxyReq = new Request(targetUrl, { - method: req.method, - headers, - body: req.body, - }); - - try { - const response = await fetch(proxyReq); - // Strip upstream Set-Cookie - const responseHeaders = new Headers(response.headers); - responseHeaders.delete("set-cookie"); - // Append refreshed session cookie if applicable - if (updatedSessionHeaders) { - for (const sc of updatedSessionHeaders.getSetCookie()) { - responseHeaders.append("Set-Cookie", sc); - } - } - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: responseHeaders, - }); - } catch (err) { - console.error("Proxy error:", err); - return Response.json( - { error: "Failed to reach upstream service" }, - { status: 502 } - ); - } -} - // ============================================================ // SSR // ============================================================ @@ -969,15 +688,6 @@ Bun.serve({ if (authResponse) return authResponse; } - // API proxy - if (url.pathname.startsWith("/api/")) { - if (USE_MOCK) { - return handleMockApi(req, url.pathname); - } else { - return proxyToEmumet(req, url.pathname); - } - } - return serveSSR(url); }, }); diff --git a/openapi/emumet.json b/openapi/emumet.json deleted file mode 100644 index 6304405..0000000 --- a/openapi/emumet.json +++ /dev/null @@ -1,1179 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "Emumet Account Service API", - "description": "Account Service for ShuttlePub", - "contact": { - "name": "turtton" - }, - "license": { - "name": "AGPL-3.0", - "url": "https://www.gnu.org/licenses/agpl-3.0.html" - }, - "version": "0.1.0" - }, - "paths": { - "/accounts": { - "get": { - "tags": [ - "Account" - ], - "description": "Retrieve accounts by IDs or with cursor-based pagination.", - "operationId": "get_accounts", - "parameters": [ - { - "name": "ids", - "in": "query", - "description": "Comma-separated account IDs", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "limit", - "in": "query", - "description": "Pagination limit", - "required": false, - "schema": { - "type": "integer", - "format": "int32", - "minimum": 0 - } - }, - { - "name": "cursor", - "in": "query", - "description": "Pagination cursor", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "direction", - "in": "query", - "description": "Pagination direction (asc/desc)", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "List of accounts", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AccountsResponse" - } - } - } - }, - "400": { - "description": "Invalid request parameters" - }, - "404": { - "description": "No accounts found" - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - }, - "post": { - "tags": [ - "Account" - ], - "description": "Create a new account with a generated signing key pair.", - "operationId": "create_account", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateAccountRequest" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "Account created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AccountResponse" - } - } - } - }, - "400": { - "description": "Invalid request" - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - } - }, - "/accounts/{account_id}/metadata": { - "post": { - "tags": [ - "Metadata" - ], - "description": "Create a metadata entry for the specified account.", - "operationId": "create_metadata", - "parameters": [ - { - "name": "account_id", - "in": "path", - "description": "Account nanoid", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateMetadataRequest" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "Metadata created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MetadataResponse" - } - } - } - }, - "400": { - "description": "Invalid request" - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - } - }, - "/accounts/{account_id}/metadata/{id}": { - "put": { - "tags": [ - "Metadata" - ], - "description": "Update a metadata entry.", - "operationId": "update_metadata", - "parameters": [ - { - "name": "account_id", - "in": "path", - "description": "Account nanoid", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "description": "Metadata nanoid", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateMetadataRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Metadata updated" - }, - "400": { - "description": "Invalid request" - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - }, - "delete": { - "tags": [ - "Metadata" - ], - "description": "Delete a metadata entry.", - "operationId": "delete_metadata", - "parameters": [ - { - "name": "account_id", - "in": "path", - "description": "Account nanoid", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "path", - "description": "Metadata nanoid", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Metadata deleted" - }, - "400": { - "description": "Invalid request" - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - } - }, - "/accounts/{account_id}/profile": { - "put": { - "tags": [ - "Profile" - ], - "description": "Update the profile of the specified account.", - "operationId": "update_profile", - "parameters": [ - { - "name": "account_id", - "in": "path", - "description": "Account nanoid", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProfileRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Profile updated" - }, - "400": { - "description": "Invalid request" - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - }, - "post": { - "tags": [ - "Profile" - ], - "description": "Create a profile for the specified account.", - "operationId": "create_profile", - "parameters": [ - { - "name": "account_id", - "in": "path", - "description": "Account nanoid", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateProfileRequest" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "Profile created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProfileResponse" - } - } - } - }, - "400": { - "description": "Invalid request" - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - } - }, - "/accounts/{id}": { - "put": { - "tags": [ - "Account" - ], - "description": "Update account properties.", - "operationId": "update_account_by_id", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Account nanoid", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateAccountRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Account updated" - }, - "400": { - "description": "Invalid request" - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - }, - "delete": { - "tags": [ - "Account" - ], - "description": "Deactivate an account and cascade-delete related resources.", - "operationId": "deactivate_account_by_id", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Account nanoid", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Account deactivated" - }, - "400": { - "description": "Invalid request" - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - } - }, - "/accounts/{id}/ban": { - "post": { - "tags": [ - "Account" - ], - "description": "Permanently ban an account.", - "operationId": "ban_account_by_id", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Account nanoid", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BanAccountRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Account banned" - }, - "400": { - "description": "Invalid request" - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - } - }, - "/accounts/{id}/suspend": { - "post": { - "tags": [ - "Account" - ], - "description": "Suspend an account with a reason and optional expiry.", - "operationId": "suspend_account_by_id", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Account nanoid", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SuspendAccountRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Account suspended" - }, - "400": { - "description": "Invalid request" - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - } - }, - "/accounts/{id}/unsuspend": { - "post": { - "tags": [ - "Account" - ], - "description": "Remove suspension from an account.", - "operationId": "unsuspend_account_by_id", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Account nanoid", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Account unsuspended" - }, - "400": { - "description": "Invalid request" - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - } - }, - "/metadata": { - "get": { - "tags": [ - "Metadata" - ], - "description": "Retrieve metadata entries for the given account IDs.", - "operationId": "get_metadata_batch", - "parameters": [ - { - "name": "account_ids", - "in": "query", - "description": "Comma-separated account IDs", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "List of metadata", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MetadataResponse" - } - } - } - } - }, - "400": { - "description": "Invalid request" - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - } - }, - "/oauth2/consent": { - "get": { - "tags": [ - "OAuth2" - ], - "description": "Retrieve consent request details or auto-accept if skip is configured.", - "operationId": "get_consent", - "parameters": [ - { - "name": "consent_challenge", - "in": "query", - "description": "Hydra consent challenge", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Consent result", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuth2Response" - } - } - } - }, - "502": { - "description": "Bad gateway (Hydra error)" - } - } - }, - "post": { - "tags": [ - "OAuth2" - ], - "description": "Submit consent decision (accept or reject).", - "operationId": "post_consent", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConsentDecision" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Consent decision result", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuth2Response" - } - } - } - }, - "400": { - "description": "Invalid scope requested" - }, - "502": { - "description": "Bad gateway (Hydra error)" - } - } - } - }, - "/oauth2/login": { - "get": { - "tags": [ - "OAuth2" - ], - "description": "Handle OAuth2 login flow. Verifies Kratos session and accepts Hydra login.", - "operationId": "login", - "parameters": [ - { - "name": "login_challenge", - "in": "query", - "description": "Hydra login challenge", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Login result (redirect on success or reject)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuth2Response" - } - } - } - }, - "502": { - "description": "Bad gateway (Hydra/Kratos error)" - } - } - } - }, - "/profiles": { - "get": { - "tags": [ - "Profile" - ], - "description": "Retrieve profiles for the given account IDs.", - "operationId": "get_profiles_batch", - "parameters": [ - { - "name": "account_ids", - "in": "query", - "description": "Comma-separated account IDs", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "List of profiles", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProfileResponse" - } - } - } - } - }, - "400": { - "description": "Invalid request" - } - }, - "security": [ - { - "bearer_auth": [] - } - ] - } - } - }, - "components": { - "schemas": { - "AccountResponse": { - "type": "object", - "required": [ - "id", - "name", - "public_key", - "is_bot", - "created_at" - ], - "properties": { - "created_at": { - "type": "string", - "format": "date-time" - }, - "id": { - "type": "string" - }, - "is_bot": { - "type": "boolean" - }, - "moderation": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/components/schemas/ModerationResponse" - } - ] - }, - "name": { - "type": "string" - }, - "public_key": { - "type": "string" - } - } - }, - "AccountsResponse": { - "type": "object", - "required": [ - "items" - ], - "properties": { - "first": { - "type": [ - "string", - "null" - ] - }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AccountResponse" - } - }, - "last": { - "type": [ - "string", - "null" - ] - } - } - }, - "BanAccountRequest": { - "type": "object", - "required": [ - "reason" - ], - "properties": { - "reason": { - "type": "string" - } - } - }, - "ConsentDecision": { - "type": "object", - "required": [ - "consent_challenge", - "accept" - ], - "properties": { - "accept": { - "type": "boolean" - }, - "consent_challenge": { - "type": "string" - }, - "grant_scope": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - } - } - }, - "CreateAccountRequest": { - "type": "object", - "required": [ - "name", - "is_bot" - ], - "properties": { - "is_bot": { - "type": "boolean" - }, - "name": { - "type": "string" - } - } - }, - "CreateMetadataRequest": { - "type": "object", - "required": [ - "label", - "content" - ], - "properties": { - "content": { - "type": "string" - }, - "label": { - "type": "string" - } - } - }, - "CreateProfileRequest": { - "type": "object", - "properties": { - "banner_url": { - "type": [ - "string", - "null" - ] - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "icon_url": { - "type": [ - "string", - "null" - ] - }, - "summary": { - "type": [ - "string", - "null" - ] - } - } - }, - "MetadataResponse": { - "type": "object", - "required": [ - "account_id", - "nanoid", - "label", - "content" - ], - "properties": { - "account_id": { - "type": "string" - }, - "content": { - "type": "string" - }, - "label": { - "type": "string" - }, - "nanoid": { - "type": "string" - } - } - }, - "ModerationResponse": { - "oneOf": [ - { - "type": "object", - "required": [ - "reason", - "suspended_at", - "type" - ], - "properties": { - "expires_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - }, - "reason": { - "type": "string" - }, - "suspended_at": { - "type": "string", - "format": "date-time" - }, - "type": { - "type": "string", - "enum": [ - "suspended" - ] - } - } - }, - { - "type": "object", - "required": [ - "reason", - "banned_at", - "type" - ], - "properties": { - "banned_at": { - "type": "string", - "format": "date-time" - }, - "reason": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "banned" - ] - } - } - } - ] - }, - "OAuth2Response": { - "oneOf": [ - { - "type": "object", - "required": [ - "redirect_to", - "action" - ], - "properties": { - "action": { - "type": "string", - "enum": [ - "redirect" - ] - }, - "redirect_to": { - "type": "string" - } - } - }, - { - "type": "object", - "required": [ - "consent_challenge", - "requested_scope", - "action" - ], - "properties": { - "action": { - "type": "string", - "enum": [ - "show_consent" - ] - }, - "client_name": { - "type": [ - "string", - "null" - ] - }, - "consent_challenge": { - "type": "string" - }, - "requested_scope": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - ] - }, - "ProfileResponse": { - "type": "object", - "required": [ - "account_id", - "nanoid" - ], - "properties": { - "account_id": { - "type": "string" - }, - "banner_url": { - "type": [ - "string", - "null" - ] - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "icon_url": { - "type": [ - "string", - "null" - ] - }, - "nanoid": { - "type": "string" - }, - "summary": { - "type": [ - "string", - "null" - ] - } - } - }, - "SuspendAccountRequest": { - "type": "object", - "required": [ - "reason" - ], - "properties": { - "expires_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - }, - "reason": { - "type": "string" - } - } - }, - "UpdateAccountRequest": { - "type": "object", - "required": [ - "is_bot" - ], - "properties": { - "is_bot": { - "type": "boolean" - } - } - }, - "UpdateMetadataRequest": { - "type": "object", - "required": [ - "label", - "content" - ], - "properties": { - "content": { - "type": "string" - }, - "label": { - "type": "string" - } - } - }, - "UpdateProfileRequest": { - "type": "object", - "properties": { - "banner_url": { - "type": [ - "string", - "null" - ], - "description": "Absent = no change, null = clear, string = set" - }, - "display_name": { - "type": [ - "string", - "null" - ], - "description": "Absent = no change, null = clear, string = set" - }, - "icon_url": { - "type": [ - "string", - "null" - ], - "description": "Absent = no change, null = clear, string = set" - }, - "summary": { - "type": [ - "string", - "null" - ], - "description": "Absent = no change, null = clear, string = set" - } - } - } - }, - "securitySchemes": { - "bearer_auth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT" - } - } - }, - "tags": [ - { - "name": "Account", - "description": "Account management" - }, - { - "name": "Profile", - "description": "Profile management" - }, - { - "name": "Metadata", - "description": "Metadata management" - }, - { - "name": "OAuth2", - "description": "OAuth2 Login/Consent Provider" - } - ] -} diff --git a/scripts/fetch-openapi.ts b/scripts/fetch-openapi.ts deleted file mode 100644 index 368f2a2..0000000 --- a/scripts/fetch-openapi.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Usage: bun scripts/fetch-openapi.ts [--ref ] - * - * Prints the resolved commit SHA to stdout for use by sync-api.ts. - */ - -const REPO_OWNER = "ShuttlePub"; -const REPO_NAME = "Emumet"; -const FILE_PATH = "openapi.json"; -const OUT_PATH = "openapi/emumet.json"; - -interface GitHubCommitResponse { - sha: string; - commit: { message: string }; -} - -async function fetchLatestCommitSha(ref: string): Promise { - const url = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/commits/${ref}`; - const res = await fetch(url, { - headers: { - Accept: "application/vnd.github.v3+json", - "User-Agent": "Ratcap-OpenAPI-Fetcher", - }, - }); - if (!res.ok) { - throw new Error( - `Failed to fetch commit info: ${res.status} ${res.statusText}`, - ); - } - const data = (await res.json()) as GitHubCommitResponse; - return data.sha; -} - -async function fetchOpenApiSpec(ref: string): Promise { - const url = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${ref}/${FILE_PATH}`; - const res = await fetch(url, { - headers: { "User-Agent": "Ratcap-OpenAPI-Fetcher" }, - }); - if (!res.ok) { - throw new Error( - `Failed to fetch ${FILE_PATH}: ${res.status} ${res.statusText}`, - ); - } - return res.text(); -} - -async function main(): Promise { - const args = process.argv.slice(2); - let ref = "main"; - const refIdx = args.indexOf("--ref"); - if (refIdx !== -1) { - const refValue = args[refIdx + 1]; - if (!refValue) { - console.error("Error: --ref requires a value (branch, tag, or SHA)"); - process.exit(1); - } - ref = refValue; - } - - console.error(`Fetching OpenAPI spec from ${REPO_OWNER}/${REPO_NAME}@${ref}...`); - - const commitSha = await fetchLatestCommitSha(ref); - console.error(`Resolved SHA: ${commitSha}`); - const specContent = await fetchOpenApiSpec(commitSha); - - try { - JSON.parse(specContent); - } catch { - console.error("Error: Fetched content is not valid JSON"); - process.exit(1); - } - - const outPath = `${import.meta.dirname}/../${OUT_PATH}`; - await Bun.write(outPath, specContent.endsWith("\n") ? specContent : specContent + "\n"); - - console.error(`Saved to ${OUT_PATH}`); - console.error(`Commit: ${REPO_OWNER}/${REPO_NAME}@${commitSha.slice(0, 12)}`); - - console.log(commitSha); -} - -main().catch((err) => { - console.error("Error:", err instanceof Error ? err.message : err); - process.exit(1); -}); diff --git a/scripts/generate-api.ts b/scripts/generate-api.ts deleted file mode 100644 index 7934d1e..0000000 --- a/scripts/generate-api.ts +++ /dev/null @@ -1,509 +0,0 @@ -import SwaggerParser from "@apidevtools/swagger-parser"; -import { join } from "path"; -import type { OpenAPIV3_1 } from "openapi-types"; - -// Usage: bun scripts/generate-api.ts [--spec ] - -const args = process.argv.slice(2); -let specPath = join(import.meta.dirname!, "..", "openapi", "emumet.json"); -for (let i = 0; i < args.length; i++) { - if (args[i] === "--spec" && args[i + 1]) specPath = args[++i]!; -} - -// --- Helpers ---------------------------------------------------------------- - -function camelCase(s: string): string { - return s - .replace(/([a-z0-9])([A-Z])/g, "$1_$2") - .split(/[_\-\s]+/) - .filter(Boolean) - .map((w, i) => - i === 0 - ? w.charAt(0).toLowerCase() + w.slice(1) - : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase() - ) - .join(""); -} - -function indent(s: string, n: number): string { - const pad = " ".repeat(n); - return s - .split("\n") - .map((l) => (l.trim() ? pad + l : l)) - .join("\n"); -} - -function wrapComplex(t: string): string { - if (t.startsWith("(") || !t.includes(" ")) return t; - return `(${t})`; -} - -// --- IR Types --------------------------------------------------------------- - -type SchemaObject = OpenAPIV3_1.SchemaObject; - -interface RecordField { - jsonName: string; - psName: string; - psType: string; - required: boolean; - isTristate: boolean; -} - -interface RecordSchema { - kind: "record"; - name: string; - fields: RecordField[]; -} - -interface UnionVariant { - constructorName: string; - discriminatorValue: string; - fields: RecordField[]; -} - -interface UnionSchema { - kind: "union"; - name: string; - discriminatorField: string; - variants: UnionVariant[]; -} - -type IRSchema = RecordSchema | UnionSchema; - -// --- $ref → schema name mapping --------------------------------------------- - -type RefMap = Map; - -function buildRefMap(raw: OpenAPIV3_1.Document, derefed: OpenAPIV3_1.Document): RefMap { - const map: RefMap = new Map(); - const rawSchemas = raw.components?.schemas ?? {}; - const derefSchemas = derefed.components?.schemas ?? {}; - - for (const [name] of Object.entries(rawSchemas)) { - const derefSchema = derefSchemas[name]; - if (derefSchema && typeof derefSchema === "object") { - map.set(derefSchema as object, name); - } - } - return map; -} - -// --- Schema → PureScript Type ----------------------------------------------- - -function mapSimpleType(t: string): string { - switch (t) { - case "string": return "String"; - case "boolean": return "Boolean"; - case "integer": return "Int"; - case "number": return "Number"; - default: return "Json"; - } -} - -function isFieldNullable(schema: SchemaObject): boolean { - if (Array.isArray(schema.type)) { - return (schema.type as string[]).includes("null"); - } - if (schema.oneOf) { - const arr = schema.oneOf as SchemaObject[]; - return arr.some((s) => s.type === "null"); - } - return false; -} - -function resolveBaseType(schema: SchemaObject, refMap: RefMap): string { - const knownName = refMap.get(schema as object); - if (knownName) return knownName; - - if (schema.oneOf) { - const nonNull = (schema.oneOf as SchemaObject[]).filter((s) => s.type !== "null"); - if (nonNull.length === 1) { - return resolveBaseType(nonNull[0]!, refMap); - } - return "Json"; - } - - if (Array.isArray(schema.type)) { - const types = (schema.type as string[]).filter((t) => t !== "null"); - if (types.length === 1) { - const t = types[0]!; - if (t === "array" && (schema as any).items) { - const itemType = resolveBaseType((schema as any).items as SchemaObject, refMap); - return `(Array ${wrapComplex(itemType)})`; - } - return mapSimpleType(t); - } - return "Json"; - } - - if (schema.type === "array" && schema.items) { - const itemType = resolveBaseType(schema.items as SchemaObject, refMap); - return `(Array ${wrapComplex(itemType)})`; - } - - if (schema.type === "object" && !schema.properties) return "Json"; - - return mapSimpleType((schema.type as string) ?? "object"); -} - -function resolvePsType(schema: SchemaObject, required: boolean, refMap: RefMap): string { - let base: string; - - const knownName = refMap.get(schema as object); - if (knownName) { - base = knownName; - if (!required) return `(Maybe ${wrapComplex(base)})`; - return base; - } - - if (schema.oneOf) { - // OpenAPI 3.1 nullable: oneOf: [{ type: "null" }, { ... }] - const nonNull = (schema.oneOf as SchemaObject[]).filter( - (s) => s.type !== "null" - ); - if (nonNull.length === 1 && schema.oneOf.length === 2) { - const inner = resolvePsType(nonNull[0]!, true, refMap); - return required - ? `(Maybe ${wrapComplex(inner)})` - : `(Maybe ${wrapComplex(inner)})`; - } - base = "Json"; - } else if (Array.isArray(schema.type)) { - // OpenAPI 3.1 nullable: type: ["string", "null"] or ["array", "null"] - const types = (schema.type as string[]).filter((t) => t !== "null"); - const isNullable = (schema.type as string[]).includes("null"); - if (types.length === 1) { - const t = types[0]!; - if (t === "array" && (schema as any).items) { - const itemType = resolvePsType((schema as any).items as SchemaObject, true, refMap); - base = `(Array ${wrapComplex(itemType)})`; - } else { - base = mapSimpleType(t); - } - if (isNullable) return `(Maybe ${wrapComplex(base)})`; - } else { - base = "Json"; - } - } else if (schema.type === "array" && schema.items) { - const itemType = resolvePsType(schema.items as SchemaObject, true, refMap); - base = `(Array ${wrapComplex(itemType)})`; - } else if (schema.type === "object" && !schema.properties) { - base = "Json"; - } else { - base = mapSimpleType((schema.type as string) ?? "object"); - } - - if (!required) return `(Maybe ${wrapComplex(base)})`; - return base; -} - -// --- IR Builder ------------------------------------------------------------- - -function findDiscriminatorValue( - variant: SchemaObject, - discriminatorField: string -): string { - const prop = variant.properties?.[discriminatorField] as SchemaObject | undefined; - if (prop?.enum && prop.enum.length === 1) return String(prop.enum[0]); - return "unknown"; -} - -function pascalCaseDiscriminator(s: string): string { - return s - .split(/[_\-\s]+/) - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(""); -} - -function buildFields(schema: SchemaObject, refMap: RefMap, schemaName: string): RecordField[] { - const required = new Set(schema.required ?? []); - const isRequestType = schemaName.includes("Request"); - const fields: RecordField[] = []; - for (const [name, prop] of Object.entries(schema.properties ?? {})) { - const p = prop as SchemaObject; - const isReq = required.has(name); - const isNullable = isFieldNullable(p); - const isTristate = isRequestType && !isReq && isNullable; - fields.push({ - jsonName: name, - psName: camelCase(name), - psType: isTristate - ? `(Tristate ${wrapComplex(resolveBaseType(p, refMap))})` - : resolvePsType(p, isReq, refMap), - required: isReq, - isTristate, - }); - } - return fields; -} - -function buildSchemaIR(api: OpenAPIV3_1.Document, refMap: RefMap): IRSchema[] { - const schemas: IRSchema[] = []; - const componentSchemas = api.components?.schemas ?? {}; - - for (const [name, schema] of Object.entries(componentSchemas)) { - const s = schema as SchemaObject; - - if (s.oneOf && (s.oneOf as SchemaObject[]).some((v) => v.type === "object")) { - const variants = s.oneOf as SchemaObject[]; - let discriminatorField = "type"; - for (const v of variants) { - if (!v.properties) continue; - for (const [fn, fp] of Object.entries(v.properties)) { - const fpSchema = fp as SchemaObject; - if (fpSchema.enum && fpSchema.enum.length === 1) { - discriminatorField = fn; - break; - } - } - } - - const irVariants: UnionVariant[] = variants.map((v) => { - const discValue = findDiscriminatorValue(v, discriminatorField); - return { - constructorName: name + pascalCaseDiscriminator(discValue), - discriminatorValue: discValue, - fields: buildFields(v, refMap, name), - }; - }); - - schemas.push({ kind: "union", name, discriminatorField, variants: irVariants }); - } else if (s.type === "object" || s.properties) { - schemas.push({ kind: "record", name, fields: buildFields(s, refMap, name) }); - } - } - - return schemas; -} - -// --- PureScript Emitter: Types Module --------------------------------------- - -function emitRecordType(schema: RecordSchema): string { - const lines: string[] = []; - const { name, fields } = schema; - - if (fields.length === 0) { - lines.push(`data ${name} = ${name}`); - lines.push(""); - lines.push(`instance EncodeJson ${name} where`); - lines.push(` encodeJson ${name} = jsonEmptyObject`); - lines.push(""); - lines.push(`instance DecodeJson ${name} where`); - lines.push(` decodeJson _ = Right ${name}`); - return lines.join("\n"); - } - - lines.push(`newtype ${name} = ${name}`); - const fieldLines = fields.map( - (f, i) => `${i === 0 ? "{ " : ", "}${f.psName} :: ${f.psType}` - ); - fieldLines.push("}"); - lines.push(indent(fieldLines.join("\n"), 2)); - lines.push(""); - - const hasTristate = fields.some((f) => f.isTristate); - - lines.push(`instance EncodeJson ${name} where`); - if (hasTristate) { - lines.push(` encodeJson (${name} r) =`); - lines.push(` fromObject`); - const chainLines: string[] = []; - for (const f of fields) { - if (f.isTristate) { - chainLines.push(`$ tristateField "${f.jsonName}" r.${f.psName}`); - } else { - chainLines.push(`$ FO.insert "${f.jsonName}" (encodeJson r.${f.psName})`); - } - } - chainLines.push("$ FO.empty"); - lines.push(indent(chainLines.join("\n"), 6)); - } else { - lines.push(` encodeJson (${name} r) =`); - const encLines = fields.map( - (f, i) => - `"${f.jsonName}" := r.${f.psName}` + - (i < fields.length - 1 ? " ~>" : " ~> jsonEmptyObject") - ); - lines.push(indent(encLines.join("\n"), 4)); - } - lines.push(""); - - lines.push(`instance DecodeJson ${name} where`); - lines.push(` decodeJson json = do`); - lines.push(` obj <- decodeJson json`); - for (const f of fields) { - if (f.isTristate) { - lines.push(` ${f.psName} <- tristateDecodeField obj "${f.jsonName}"`); - } else { - const isMaybe = f.psType.startsWith("(Maybe "); - if (isMaybe && !f.required) { - lines.push(` ${f.psName} <- join <$> obj .:? "${f.jsonName}"`); - } else { - const op = f.required ? ".:" : ".:?"; - lines.push(` ${f.psName} <- obj ${op} "${f.jsonName}"`); - } - } - } - const fieldAssignments = fields.map((f) => f.psName).join(", "); - lines.push(` pure (${name} { ${fieldAssignments} })`); - - return lines.join("\n"); -} - -function emitUnionType(schema: UnionSchema): string { - const lines: string[] = []; - const { name, discriminatorField, variants } = schema; - - const ctors = variants.map((v) => { - const nonDiscFields = v.fields.filter((f) => f.jsonName !== discriminatorField); - if (nonDiscFields.length === 0) return v.constructorName; - const recFields = nonDiscFields.map( - (f, i) => `${i === 0 ? "{ " : ", "}${f.psName} :: ${f.psType}` - ); - recFields.push("}"); - return `${v.constructorName}\n${indent(recFields.join("\n"), 4)}`; - }); - - lines.push(`data ${name}`); - ctors.forEach((c, i) => { - lines.push(` ${i === 0 ? "= " : "| "}${c}`); - }); - lines.push(""); - - lines.push(`instance EncodeJson ${name} where`); - for (const v of variants) { - const nonDiscFields = v.fields.filter((f) => f.jsonName !== discriminatorField); - if (nonDiscFields.length === 0) { - lines.push(` encodeJson ${v.constructorName} =`); - lines.push(` "${discriminatorField}" := "${v.discriminatorValue}" ~> jsonEmptyObject`); - } else { - lines.push(` encodeJson (${v.constructorName} r) =`); - lines.push(` "${discriminatorField}" := "${v.discriminatorValue}" ~>`); - const encLines = nonDiscFields.map( - (f, i) => - `"${f.jsonName}" := r.${f.psName}` + - (i < nonDiscFields.length - 1 ? " ~>" : " ~> jsonEmptyObject") - ); - lines.push(indent(encLines.join("\n"), 4)); - } - } - lines.push(""); - - lines.push(`instance DecodeJson ${name} where`); - lines.push(` decodeJson json = do`); - lines.push(` obj <- decodeJson json`); - lines.push(` tag <- obj .: "${discriminatorField}"`); - lines.push(` case (tag :: String) of`); - for (const v of variants) { - const nonDiscFields = v.fields.filter((f) => f.jsonName !== discriminatorField); - if (nonDiscFields.length === 0) { - lines.push(` "${v.discriminatorValue}" -> Right ${v.constructorName}`); - } else { - lines.push(` "${v.discriminatorValue}" -> do`); - for (const f of nonDiscFields) { - if (f.isTristate) { - lines.push(` ${f.psName} <- tristateDecodeField obj "${f.jsonName}"`); - } else { - const isMaybe = f.psType.startsWith("(Maybe "); - if (isMaybe && !f.required) { - lines.push(` ${f.psName} <- join <$> obj .:? "${f.jsonName}"`); - } else { - const op = f.required ? ".:" : ".:?"; - lines.push(` ${f.psName} <- obj ${op} "${f.jsonName}"`); - } - } - } - const fieldAssignments = nonDiscFields.map((f) => f.psName).join(", "); - lines.push(` pure (${v.constructorName} { ${fieldAssignments} })`); - } - } - lines.push(` other -> Left (UnexpectedValue (encodeJson other))`); - - return lines.join("\n"); -} - -function collectUsedImports(schemas: IRSchema[]): { needsJson: boolean; needsTristate: boolean } { - let needsJson = false; - let needsTristate = false; - for (const s of schemas) { - const fields = s.kind === "record" ? s.fields : s.variants.flatMap((v) => v.fields); - for (const f of fields) { - if (f.psType === "Json" || f.psType.includes(" Json)")) needsJson = true; - if (f.isTristate) needsTristate = true; - } - } - return { needsJson, needsTristate }; -} - -function emitTypesModule(schemas: IRSchema[]): string { - const lines: string[] = []; - const { needsJson, needsTristate } = collectUsedImports(schemas); - const argonautCoreImports: string[] = []; - if (needsTristate) argonautCoreImports.push("fromObject"); - if (needsJson) argonautCoreImports.push("Json"); - argonautCoreImports.push("jsonEmptyObject"); - - lines.push("-- Auto-generated from OpenAPI spec. DO NOT EDIT."); - lines.push("module App.Api.Emumet.Types where"); - lines.push(""); - lines.push("import Prelude"); - lines.push(""); - if (needsTristate) { - lines.push("import App.Api.Emumet.Tristate (Tristate, tristateField, tristateDecodeField)"); - } - lines.push(`import Data.Argonaut.Core (${argonautCoreImports.join(", ")})`); - lines.push("import Data.Argonaut.Decode (class DecodeJson, decodeJson, (.:), (.:?))"); - lines.push("import Data.Argonaut.Decode.Error (JsonDecodeError(..))"); - lines.push("import Data.Argonaut.Encode (class EncodeJson, encodeJson, (:=), (~>))"); - lines.push("import Data.Either (Either(..))"); - lines.push("import Data.Maybe (Maybe)"); - if (needsTristate) { - lines.push("import Foreign.Object as FO"); - } - lines.push(""); - - for (const schema of schemas) { - lines.push( - schema.kind === "record" - ? emitRecordType(schema) - : emitUnionType(schema) - ); - lines.push(""); - } - - return lines.join("\n"); -} - -// --- Main ------------------------------------------------------------------- - -async function main() { - console.error(`Reading spec: ${specPath}`); - - const rawJson = JSON.parse(await Bun.file(specPath).text()) as OpenAPIV3_1.Document; - const api = (await SwaggerParser.dereference( - JSON.parse(JSON.stringify(rawJson)) - )) as OpenAPIV3_1.Document; - - console.error( - `Parsed: ${api.info.title} v${api.info.version} — ` + - `${Object.keys(api.paths ?? {}).length} paths, ` + - `${Object.keys(api.components?.schemas ?? {}).length} schemas` - ); - - const refMap = buildRefMap(rawJson, api); - const schemas = buildSchemaIR(api, refMap); - console.error(`IR: ${schemas.length} schemas`); - - const typesCode = emitTypesModule(schemas); - const outDir = join(import.meta.dirname!, "..", "src", "App", "Api", "Emumet"); - await Bun.write(join(outDir, "Types.purs"), typesCode); - console.error(` → ${outDir}/Types.purs`); - - console.error("Done."); -} - -main().catch((err) => { - console.error("Error:", err); - process.exit(1); -}); diff --git a/scripts/sync-api.ts b/scripts/sync-api.ts deleted file mode 100644 index 42f592f..0000000 --- a/scripts/sync-api.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Usage: bun scripts/sync-api.ts [--ref ] [--commit] [--generate-only] - * - * Orchestrates OpenAPI spec fetch + PureScript code generation. - * - * Flags: - * --ref Branch, tag, or SHA to fetch from (default: main) - * --commit Auto-commit generated files with Emumet SHA in message - * --generate-only Skip fetch, only regenerate from existing openapi/emumet.json - */ - -import { $ } from "bun"; - -async function main(): Promise { - const args = process.argv.slice(2); - const doCommit = args.includes("--commit"); - const generateOnly = args.includes("--generate-only"); - - const scriptsDir = import.meta.dirname!; - let commitSha: string | undefined; - - if (!generateOnly) { - const fetchArgs = ["bun", `${scriptsDir}/fetch-openapi.ts`]; - const refIdx = args.indexOf("--ref"); - if (refIdx !== -1) { - const refValue = args[refIdx + 1]; - if (!refValue) { - console.error("Error: --ref requires a value"); - process.exit(1); - } - fetchArgs.push("--ref", refValue); - } - - console.error("=== Step 1: Fetching OpenAPI spec ==="); - const fetchResult = await Bun.spawn(fetchArgs, { - stdout: "pipe", - stderr: "inherit", - }); - - const exitCode = await fetchResult.exited; - if (exitCode !== 0) { - console.error("Fetch failed"); - process.exit(1); - } - - commitSha = (await new Response(fetchResult.stdout).text()).trim(); - console.error(`Resolved commit SHA: ${commitSha}`); - } else { - console.error("=== Step 1: Skipped (--generate-only) ==="); - } - - console.error("\n=== Step 2: Generating PureScript code ==="); - const genResult = await Bun.spawn(["bun", `${scriptsDir}/generate-api.ts`], { - stdout: "inherit", - stderr: "inherit", - }); - - const genExitCode = await genResult.exited; - if (genExitCode !== 0) { - console.error("Code generation failed"); - process.exit(1); - } - - if (doCommit) { - console.error("\n=== Step 3: Committing changes ==="); - - const shaLabel = commitSha - ? `ShuttlePub/Emumet@${commitSha.slice(0, 12)}` - : "existing spec"; - - const commitMsg = commitSha - ? `Update Emumet OpenAPI spec from ${shaLabel}\n\nFull SHA: ${commitSha}` - : `Regenerate API code from ${shaLabel}`; - - await $`git add openapi/emumet.json src/App/Api/Emumet/Types.purs`; - - const diffResult = await Bun.spawn(["git", "diff", "--cached", "--quiet"], { - stdout: "inherit", - stderr: "inherit", - }); - const diffExitCode = await diffResult.exited; - - if (diffExitCode === 0) { - console.error("No changes to commit — skipping."); - } else { - await $`git commit -m ${commitMsg}`; - console.error("Committed."); - } - } else { - console.error("\n=== Step 3: Skipped (no --commit flag) ==="); - } - - console.error("\nDone."); -} - -main().catch((err) => { - console.error("Error:", err instanceof Error ? err.message : err); - process.exit(1); -}); diff --git a/src/App/Api/Emumet/Client.purs b/src/App/Api/Emumet/Client.purs deleted file mode 100644 index 62ac31c..0000000 --- a/src/App/Api/Emumet/Client.purs +++ /dev/null @@ -1,68 +0,0 @@ -module App.Api.Emumet.Client where - -import Prelude - -import App.Api.Client as Api -import App.Api.Client (ApiError(..)) -import App.Api.Emumet.Types - ( AccountResponse - , AccountsResponse(..) - , CreateAccountRequest - , CreateMetadataRequest - , MetadataResponse - , ProfileResponse - , UpdateMetadataRequest - , UpdateProfileRequest - ) -import Data.Array (head) -import Data.Either (Either(..)) -import Data.Maybe (maybe) -import Effect.Aff (Aff) - --- Base path for all Emumet API calls (proxied through Bun) -basePath :: String -basePath = "/api" - --- | GET /accounts -> Array AccountResponse -fetchAccounts :: Aff (Either ApiError (Array AccountResponse)) -fetchAccounts = do - result <- Api.get (basePath <> "/accounts") - pure $ map (\(AccountsResponse r) -> r.items) result - --- | GET /accounts?ids=:id -> AccountResponse (extracted from items) -fetchAccount :: String -> Aff (Either ApiError AccountResponse) -fetchAccount targetId = do - result <- Api.get (basePath <> "/accounts?ids=" <> targetId) - pure $ result >>= \(AccountsResponse r) -> - maybe (Left (HttpError 404 "Account not found")) Right (head r.items) - --- | POST /accounts -> AccountResponse -createAccount :: CreateAccountRequest -> Aff (Either ApiError AccountResponse) -createAccount = Api.post (basePath <> "/accounts") - --- | GET /profiles?account_ids=:id -> ProfileResponse (extracted from array) -fetchProfile :: String -> Aff (Either ApiError ProfileResponse) -fetchProfile accountId = do - result <- Api.get (basePath <> "/profiles?account_ids=" <> accountId) - pure $ result >>= \(profiles :: Array ProfileResponse) -> - maybe (Left (HttpError 404 "Profile not found")) Right (head profiles) - --- | PUT /accounts/:id/profile -> 204 No Content -updateProfile :: String -> UpdateProfileRequest -> Aff (Either ApiError Unit) -updateProfile accountId = Api.putUnit (basePath <> "/accounts/" <> accountId <> "/profile") - --- | GET /metadata?account_ids=:id -> Array MetadataResponse -fetchMetadata :: String -> Aff (Either ApiError (Array MetadataResponse)) -fetchMetadata accountId = Api.get (basePath <> "/metadata?account_ids=" <> accountId) - --- | POST /accounts/:id/metadata -> MetadataResponse -createMetadata :: String -> CreateMetadataRequest -> Aff (Either ApiError MetadataResponse) -createMetadata accountId = Api.post (basePath <> "/accounts/" <> accountId <> "/metadata") - --- | PUT /accounts/:id/metadata/:nanoid -> 204 No Content -updateMetadata :: String -> String -> UpdateMetadataRequest -> Aff (Either ApiError Unit) -updateMetadata accountId nanoid = Api.putUnit (basePath <> "/accounts/" <> accountId <> "/metadata/" <> nanoid) - --- | DELETE /accounts/:id/metadata/:nanoid -deleteMetadata :: String -> String -> Aff (Either ApiError Unit) -deleteMetadata accountId nanoid = Api.delete (basePath <> "/accounts/" <> accountId <> "/metadata/" <> nanoid) diff --git a/src/App/Api/Emumet/Tristate.purs b/src/App/Api/Emumet/Tristate.purs deleted file mode 100644 index e4446e0..0000000 --- a/src/App/Api/Emumet/Tristate.purs +++ /dev/null @@ -1,55 +0,0 @@ -module App.Api.Emumet.Tristate where - -import Prelude - -import Data.Argonaut.Core (Json, isNull, jsonNull) -import Data.Argonaut.Decode (class DecodeJson, decodeJson) -import Data.Argonaut.Decode.Error (JsonDecodeError) -import Data.Argonaut.Encode (class EncodeJson, encodeJson) -import Data.Either (Either(..)) -import Data.Maybe (Maybe(..)) - -import Foreign.Object as FO - --- | Tri-state type for optional+nullable JSON fields. --- | - `Omitted` — field is absent from JSON (no change) --- | - `SetNull` — field is explicitly `null` (clear value) --- | - `Value a` — field has a value -data Tristate a = Omitted | SetNull | Value a - -derive instance eqTristate :: Eq a => Eq (Tristate a) - -instance showTristate :: Show a => Show (Tristate a) where - show Omitted = "Omitted" - show SetNull = "SetNull" - show (Value a) = "(Value " <> show a <> ")" - -instance encodeJsonTristate :: EncodeJson a => EncodeJson (Tristate a) where - encodeJson Omitted = jsonNull - encodeJson SetNull = jsonNull - encodeJson (Value a) = encodeJson a - -instance decodeJsonTristate :: DecodeJson a => DecodeJson (Tristate a) where - decodeJson json = do - (m :: Maybe a) <- decodeJson json - pure case m of - Nothing -> SetNull - Just a -> Value a - --- | Insert a Tristate field into a Foreign.Object builder. --- | Omitted → skip, SetNull → insert null, Value → insert encoded. -tristateField :: forall a. EncodeJson a => String -> Tristate a -> FO.Object Json -> FO.Object Json -tristateField _ Omitted obj = obj -tristateField key SetNull obj = FO.insert key jsonNull obj -tristateField key (Value a) obj = FO.insert key (encodeJson a) obj - --- | Decode a Tristate field from a decoded JSON object. --- | Key absent → Omitted, key present+null → SetNull, key present+value → Value. -tristateDecodeField :: forall a. DecodeJson a => FO.Object Json -> String -> Either JsonDecodeError (Tristate a) -tristateDecodeField obj key = case FO.lookup key obj of - Nothing -> Right Omitted - Just json - | isNull json -> Right SetNull - | otherwise -> case decodeJson json of - Left err -> Left err - Right a -> Right (Value a) diff --git a/src/App/Api/Emumet/Types.purs b/src/App/Api/Emumet/Types.purs deleted file mode 100644 index 77d2f7e..0000000 --- a/src/App/Api/Emumet/Types.purs +++ /dev/null @@ -1,367 +0,0 @@ --- Auto-generated from OpenAPI spec. DO NOT EDIT. -module App.Api.Emumet.Types where - -import Prelude - -import App.Api.Emumet.Tristate (Tristate, tristateField, tristateDecodeField) -import Data.Argonaut.Core (fromObject, jsonEmptyObject) -import Data.Argonaut.Decode (class DecodeJson, decodeJson, (.:), (.:?)) -import Data.Argonaut.Decode.Error (JsonDecodeError(..)) -import Data.Argonaut.Encode (class EncodeJson, encodeJson, (:=), (~>)) -import Data.Either (Either(..)) -import Data.Maybe (Maybe) -import Foreign.Object as FO - -newtype AccountResponse = AccountResponse - { createdAt :: String - , id :: String - , isBot :: Boolean - , moderation :: (Maybe ModerationResponse) - , name :: String - , publicKey :: String - } - -instance EncodeJson AccountResponse where - encodeJson (AccountResponse r) = - "created_at" := r.createdAt - ~> "id" := r.id - ~> "is_bot" := r.isBot - ~> "moderation" := r.moderation - ~> "name" := r.name - ~> "public_key" := r.publicKey - ~> jsonEmptyObject - -instance DecodeJson AccountResponse where - decodeJson json = do - obj <- decodeJson json - createdAt <- obj .: "created_at" - id <- obj .: "id" - isBot <- obj .: "is_bot" - moderation <- join <$> obj .:? "moderation" - name <- obj .: "name" - publicKey <- obj .: "public_key" - pure (AccountResponse { createdAt, id, isBot, moderation, name, publicKey }) - -newtype AccountsResponse = AccountsResponse - { first :: (Maybe String) - , items :: (Array AccountResponse) - , last :: (Maybe String) - } - -instance EncodeJson AccountsResponse where - encodeJson (AccountsResponse r) = - "first" := r.first - ~> "items" := r.items - ~> "last" := r.last - ~> jsonEmptyObject - -instance DecodeJson AccountsResponse where - decodeJson json = do - obj <- decodeJson json - first <- join <$> obj .:? "first" - items <- obj .: "items" - last <- join <$> obj .:? "last" - pure (AccountsResponse { first, items, last }) - -newtype BanAccountRequest = BanAccountRequest - { reason :: String - } - -instance EncodeJson BanAccountRequest where - encodeJson (BanAccountRequest r) = - "reason" := r.reason ~> jsonEmptyObject - -instance DecodeJson BanAccountRequest where - decodeJson json = do - obj <- decodeJson json - reason <- obj .: "reason" - pure (BanAccountRequest { reason }) - -newtype ConsentDecision = ConsentDecision - { accept :: Boolean - , consentChallenge :: String - , grantScope :: (Maybe (Array String)) - } - -instance EncodeJson ConsentDecision where - encodeJson (ConsentDecision r) = - "accept" := r.accept - ~> "consent_challenge" := r.consentChallenge - ~> "grant_scope" := r.grantScope - ~> jsonEmptyObject - -instance DecodeJson ConsentDecision where - decodeJson json = do - obj <- decodeJson json - accept <- obj .: "accept" - consentChallenge <- obj .: "consent_challenge" - grantScope <- join <$> obj .:? "grant_scope" - pure (ConsentDecision { accept, consentChallenge, grantScope }) - -newtype CreateAccountRequest = CreateAccountRequest - { isBot :: Boolean - , name :: String - } - -instance EncodeJson CreateAccountRequest where - encodeJson (CreateAccountRequest r) = - "is_bot" := r.isBot - ~> "name" := r.name - ~> jsonEmptyObject - -instance DecodeJson CreateAccountRequest where - decodeJson json = do - obj <- decodeJson json - isBot <- obj .: "is_bot" - name <- obj .: "name" - pure (CreateAccountRequest { isBot, name }) - -newtype CreateMetadataRequest = CreateMetadataRequest - { content :: String - , label :: String - } - -instance EncodeJson CreateMetadataRequest where - encodeJson (CreateMetadataRequest r) = - "content" := r.content - ~> "label" := r.label - ~> jsonEmptyObject - -instance DecodeJson CreateMetadataRequest where - decodeJson json = do - obj <- decodeJson json - content <- obj .: "content" - label <- obj .: "label" - pure (CreateMetadataRequest { content, label }) - -newtype CreateProfileRequest = CreateProfileRequest - { bannerUrl :: (Tristate String) - , displayName :: (Tristate String) - , iconUrl :: (Tristate String) - , summary :: (Tristate String) - } - -instance EncodeJson CreateProfileRequest where - encodeJson (CreateProfileRequest r) = - fromObject - $ tristateField "banner_url" r.bannerUrl - $ tristateField "display_name" r.displayName - $ tristateField "icon_url" r.iconUrl - $ tristateField "summary" r.summary - $ FO.empty - -instance DecodeJson CreateProfileRequest where - decodeJson json = do - obj <- decodeJson json - bannerUrl <- tristateDecodeField obj "banner_url" - displayName <- tristateDecodeField obj "display_name" - iconUrl <- tristateDecodeField obj "icon_url" - summary <- tristateDecodeField obj "summary" - pure (CreateProfileRequest { bannerUrl, displayName, iconUrl, summary }) - -newtype MetadataResponse = MetadataResponse - { accountId :: String - , content :: String - , label :: String - , nanoid :: String - } - -instance EncodeJson MetadataResponse where - encodeJson (MetadataResponse r) = - "account_id" := r.accountId - ~> "content" := r.content - ~> "label" := r.label - ~> "nanoid" := r.nanoid - ~> jsonEmptyObject - -instance DecodeJson MetadataResponse where - decodeJson json = do - obj <- decodeJson json - accountId <- obj .: "account_id" - content <- obj .: "content" - label <- obj .: "label" - nanoid <- obj .: "nanoid" - pure (MetadataResponse { accountId, content, label, nanoid }) - -data ModerationResponse - = ModerationResponseSuspended - { expiresAt :: (Maybe String) - , reason :: String - , suspendedAt :: String - } - | ModerationResponseBanned - { bannedAt :: String - , reason :: String - } - -instance EncodeJson ModerationResponse where - encodeJson (ModerationResponseSuspended r) = - "type" := "suspended" - ~> "expires_at" := r.expiresAt - ~> "reason" := r.reason - ~> "suspended_at" := r.suspendedAt - ~> jsonEmptyObject - encodeJson (ModerationResponseBanned r) = - "type" := "banned" - ~> "banned_at" := r.bannedAt - ~> "reason" := r.reason - ~> jsonEmptyObject - -instance DecodeJson ModerationResponse where - decodeJson json = do - obj <- decodeJson json - tag <- obj .: "type" - case (tag :: String) of - "suspended" -> do - expiresAt <- join <$> obj .:? "expires_at" - reason <- obj .: "reason" - suspendedAt <- obj .: "suspended_at" - pure (ModerationResponseSuspended { expiresAt, reason, suspendedAt }) - "banned" -> do - bannedAt <- obj .: "banned_at" - reason <- obj .: "reason" - pure (ModerationResponseBanned { bannedAt, reason }) - other -> Left (UnexpectedValue (encodeJson other)) - -data OAuth2Response - = OAuth2ResponseRedirect - { redirectTo :: String - } - | OAuth2ResponseShowConsent - { clientName :: (Maybe String) - , consentChallenge :: String - , requestedScope :: (Array String) - } - -instance EncodeJson OAuth2Response where - encodeJson (OAuth2ResponseRedirect r) = - "action" := "redirect" - ~> "redirect_to" := r.redirectTo - ~> jsonEmptyObject - encodeJson (OAuth2ResponseShowConsent r) = - "action" := "show_consent" - ~> "client_name" := r.clientName - ~> "consent_challenge" := r.consentChallenge - ~> "requested_scope" := r.requestedScope - ~> jsonEmptyObject - -instance DecodeJson OAuth2Response where - decodeJson json = do - obj <- decodeJson json - tag <- obj .: "action" - case (tag :: String) of - "redirect" -> do - redirectTo <- obj .: "redirect_to" - pure (OAuth2ResponseRedirect { redirectTo }) - "show_consent" -> do - clientName <- join <$> obj .:? "client_name" - consentChallenge <- obj .: "consent_challenge" - requestedScope <- obj .: "requested_scope" - pure (OAuth2ResponseShowConsent { clientName, consentChallenge, requestedScope }) - other -> Left (UnexpectedValue (encodeJson other)) - -newtype ProfileResponse = ProfileResponse - { accountId :: String - , bannerUrl :: (Maybe String) - , displayName :: (Maybe String) - , iconUrl :: (Maybe String) - , nanoid :: String - , summary :: (Maybe String) - } - -instance EncodeJson ProfileResponse where - encodeJson (ProfileResponse r) = - "account_id" := r.accountId - ~> "banner_url" := r.bannerUrl - ~> "display_name" := r.displayName - ~> "icon_url" := r.iconUrl - ~> "nanoid" := r.nanoid - ~> "summary" := r.summary - ~> jsonEmptyObject - -instance DecodeJson ProfileResponse where - decodeJson json = do - obj <- decodeJson json - accountId <- obj .: "account_id" - bannerUrl <- join <$> obj .:? "banner_url" - displayName <- join <$> obj .:? "display_name" - iconUrl <- join <$> obj .:? "icon_url" - nanoid <- obj .: "nanoid" - summary <- join <$> obj .:? "summary" - pure (ProfileResponse { accountId, bannerUrl, displayName, iconUrl, nanoid, summary }) - -newtype SuspendAccountRequest = SuspendAccountRequest - { expiresAt :: (Tristate String) - , reason :: String - } - -instance EncodeJson SuspendAccountRequest where - encodeJson (SuspendAccountRequest r) = - fromObject - $ tristateField "expires_at" r.expiresAt - $ FO.insert "reason" (encodeJson r.reason) - $ FO.empty - -instance DecodeJson SuspendAccountRequest where - decodeJson json = do - obj <- decodeJson json - expiresAt <- tristateDecodeField obj "expires_at" - reason <- obj .: "reason" - pure (SuspendAccountRequest { expiresAt, reason }) - -newtype UpdateAccountRequest = UpdateAccountRequest - { isBot :: Boolean - } - -instance EncodeJson UpdateAccountRequest where - encodeJson (UpdateAccountRequest r) = - "is_bot" := r.isBot ~> jsonEmptyObject - -instance DecodeJson UpdateAccountRequest where - decodeJson json = do - obj <- decodeJson json - isBot <- obj .: "is_bot" - pure (UpdateAccountRequest { isBot }) - -newtype UpdateMetadataRequest = UpdateMetadataRequest - { content :: String - , label :: String - } - -instance EncodeJson UpdateMetadataRequest where - encodeJson (UpdateMetadataRequest r) = - "content" := r.content - ~> "label" := r.label - ~> jsonEmptyObject - -instance DecodeJson UpdateMetadataRequest where - decodeJson json = do - obj <- decodeJson json - content <- obj .: "content" - label <- obj .: "label" - pure (UpdateMetadataRequest { content, label }) - -newtype UpdateProfileRequest = UpdateProfileRequest - { bannerUrl :: (Tristate String) - , displayName :: (Tristate String) - , iconUrl :: (Tristate String) - , summary :: (Tristate String) - } - -instance EncodeJson UpdateProfileRequest where - encodeJson (UpdateProfileRequest r) = - fromObject - $ tristateField "banner_url" r.bannerUrl - $ tristateField "display_name" r.displayName - $ tristateField "icon_url" r.iconUrl - $ tristateField "summary" r.summary - $ FO.empty - -instance DecodeJson UpdateProfileRequest where - decodeJson json = do - obj <- decodeJson json - bannerUrl <- tristateDecodeField obj "banner_url" - displayName <- tristateDecodeField obj "display_name" - iconUrl <- tristateDecodeField obj "icon_url" - summary <- tristateDecodeField obj "summary" - pure (UpdateProfileRequest { bannerUrl, displayName, iconUrl, summary }) diff --git a/src/App/Api/GraphQL/Types.purs b/src/App/Api/GraphQL/Types.purs index 5359ce7..a02e0ec 100644 --- a/src/App/Api/GraphQL/Types.purs +++ b/src/App/Api/GraphQL/Types.purs @@ -10,7 +10,7 @@ import Data.Either (Either(..)) import Data.Maybe (Maybe(..)) import Foreign.Object as FO --- Response DTOs (same names/shapes as src/App/Api/Emumet/Types.purs) +-- Response DTOs (app-owned stable DTOs, decoupled from the BFF schema) newtype AccountResponse = AccountResponse { createdAt :: String From a62e1d3c7e447bd344455ace3d89dc36120d2591 Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 13:58:23 +0900 Subject: [PATCH 14/17] docs: update README and AGENTS.md for GraphQL BFF architecture --- CLAUDE.md | 34 ++++++++++++++++++++++++---------- README.md | 14 +++++++++++++- bff/emumet/real.ts | 2 +- scripts/sync-graphql.ts | 5 ++--- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bcb93a7..d46121b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,8 @@ Development tools are managed via Nix flake + direnv. Entering the project direc - **Bundle server:** `spago bundle --platform node --module Server --outfile dist/server.js --bundle-type module` - **Bundle CSS:** `bunx @tailwindcss/cli -i src/style.css -o dist/style.css` - **Run tests:** `spago test` +- **BFF tests:** `bun test` +- **GraphQL codegen:** `bun scripts/sync-graphql.ts` (regenerate PureScript types from `bff/schema.graphql`) - **Install JS dependencies:** `bun install` ## Architecture @@ -27,17 +29,32 @@ Development tools are managed via Nix flake + direnv. Entering the project direc - **SSR:** `Server.purs` renders full HTML via `Flame.Renderer.String`, with serialized state embedded in `` for hydration - **Client:** `Client.purs` hydrates SSR HTML via `Flame.resumeMount`, then handles client-side routing with `matchesWith` - **Styling:** Tailwind CSS v4 — utility classes applied directly via `HA.class'` in PureScript views; `src/style.css` is the entry point with `@source "../src/**/*.purs"` to scan PureScript files for class names -- **Auth (BFF):** `index.ts` implements BFF (Backend-for-Frontend) auth — mock mode (built-in) and real mode (Kratos + Hydra OAuth2 PKCE). Session is stored in an AES-GCM encrypted HttpOnly cookie (`ratcap_session`). `/api/*` proxy injects Bearer token from session cookie. -- **Dev server:** `index.ts` — Bun.serve() handles SSR (all routes), static files (`/app.js`, `/style.css`), auth endpoints (`/auth/*`), and API proxy (`/api/*`) +- **Auth (BFF):** `index.ts` implements BFF (Backend-for-Frontend) auth — mock mode (built-in) and real mode (Kratos + Hydra OAuth2 PKCE). Session is stored in an AES-GCM encrypted HttpOnly cookie (`ratcap_session`). +- **Data API (BFF):** `bff/server.ts` serves `/graphql` via graphql-yoga. The schema is defined in `bff/schema.graphql` (single source of truth), with resolvers in `bff/resolvers.ts`. Session resolution happens in `bff/context.ts` via `SessionAdapter` DI. DataLoaders in `bff/loaders.ts` batch Emumet API calls per request. Errors use `extensions.code` (UNAUTHENTICATED / NOT_FOUND / INTERNAL_SERVER_ERROR). Set-Cookie via WeakMap + yoga onResponse plugin. +- **Dev server:** `index.ts` — Bun.serve() handles SSR (all routes), static files (`/app.js`, `/style.css`), auth endpoints (`/auth/*`), and GraphQL (`/graphql`) - **Dev script:** `scripts/dev.sh` — triple bundle (client + server + CSS) + watchexec (auto-rebuild on `.purs` changes) + Tailwind `--watch` + Bun dev server -- **PureScript packages:** managed by `spago.yaml`, uses registry package set +- **PureScript packages:** managed by `spago.yaml`, uses registry package set (includes `graphql-client` for /graphql queries) - **JS dependencies:** managed by `package.json` / `bun.lock` ### Module Structure ``` +bff/ + schema.graphql -- GraphQL SDL (single source of truth for the data API) + schema.ts -- SDL loader + makeExecutableSchema + server.ts -- Yoga handler: createYogaHandler(adapter, createEmumetClient) + context.ts -- Session resolution + auth context (SessionAdapter DI) + session.ts -- Session foundation: seal/unseal, cookie helpers, refresh, SessionAdapter interface + loaders.ts -- DataLoader: profile + metadata, request-scoped, memoized per request + resolvers.ts -- Query/Mutation resolvers + emumet/ + client.ts -- EmumetClient interface + DTOs (camelCase) + real.ts -- REST-backed EmumetClient (snake_case ↔ camelCase mapping) + mock.ts -- In-memory stateful MockEmumetClient + *.test.ts -- BFF test files (bun test) src/ style.css -- Tailwind CSS entry point (@import "tailwindcss" + @source) + Generated/ -- Auto-generated PureScript GraphQL types (from bff/schema.graphql via sync-graphql.ts; never hand-edit) App/ Route.purs -- Route ADT (Home, Settings, AccountNew, AccountDetail, Login) + routing-duplex codec Model.purs -- Model type (Maybe Route, PageModel, SessionInfo, isHydrated) + JSON instances @@ -46,10 +63,9 @@ src/ Api/ Client.purs -- Generic HTTP client (Affjax-based: get, post, put, delete) Auth.purs -- Auth API calls (login, checkSession, logout) via BFF /auth/* endpoints - Emumet/ - Client.purs -- Emumet API wrappers over Api.Client - Types.purs -- Auto-generated Emumet API types (DO NOT EDIT) - Tristate.purs -- Tristate type for optional update fields + GraphQL.purs -- GraphQL client (graphql-client): queries + mutations over /graphql + GraphQL/ + Types.purs -- App-owned stable DTOs (AccountResponse, ProfileResponse, etc.) View/ Layout.purs -- HTML shell (//) + auth-aware navigation Login.purs -- Login page view (email + password form) @@ -72,9 +88,7 @@ src/ scripts/ dev.sh -- Dev server (build + watch + serve) register-hydra-client.ts -- Register OAuth2 client in Hydra (real mode setup) - fetch-openapi.ts -- Fetch OpenAPI spec from Emumet - generate-api.ts -- Generate PureScript types from OpenAPI spec - sync-api.ts -- Sync API types (fetch + generate) + sync-graphql.ts -- Generate PureScript types from bff/schema.graphql (SDL → introspection → src/Generated/) ``` ### Key Design Decisions diff --git a/README.md b/README.md index f5142b2..6271902 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,14 @@ COOKIE_SECRET_BASE64="$YOUR_PERSISTENT_SECRET" USE_MOCK=false bun index.ts ```bash spago build # ビルド spago test # テスト +bun test # BFF テスト (bff/ 配下の Jest テスト) bun index.ts # サーバー起動(要事前ビルド) +bun scripts/sync-graphql.ts # SDL → PureScript 型生成 (bff/schema.graphql 変更時) ``` ## 認証 -`index.ts` は BFF (Backend-for-Frontend) パターンで認証を処理します。Mock モードと Real モード (Kratos + Hydra) の 2 つの動作モードがあります。 +`index.ts` は BFF (Backend-for-Frontend) パターンで認証とデータ API を処理します。データ API は graphql-yoga を使った `/graphql` エンドポイントで提供され、GraphQL クライアントは PureScript 側で `graphql-client` ライブラリを使います。認証は引き続き REST のままです (`/auth/*` エンドポイント)。Mock モードと Real モード (Kratos + Hydra) の 2 つの動作モードがあります。 ### 起動モード @@ -113,3 +115,13 @@ bun scripts/register-hydra-client.ts - **メールアドレス**: 任意の文字列 - **パスワード**: `password` + +### GraphQL の型再生成 + +SDL スキーマ (`bff/schema.graphql`) を変更した場合、PureScript 側の型を再生成する必要があります。 + +```bash +bun scripts/sync-graphql.ts +``` + +`src/Generated/` 配下の型が再生成されます。生成物は git で管理 (コミット) する運用です。`--schema ` フラグで別の SDL ファイルを指定することもできます。 diff --git a/bff/emumet/real.ts b/bff/emumet/real.ts index 2333637..810c837 100644 --- a/bff/emumet/real.ts +++ b/bff/emumet/real.ts @@ -1,5 +1,5 @@ // ============================================================ -// RealEmumetClient — Emumet REST API 実装 (openapi/emumet.json 準拠) +// RealEmumetClient — Emumet REST API 実装 (REST 契約に準拠) // ワイヤ形式は snake_case、DTO (client.ts) は camelCase。境界で相互変換する。 // ============================================================ diff --git a/scripts/sync-graphql.ts b/scripts/sync-graphql.ts index c6de9e0..42b0668 100644 --- a/scripts/sync-graphql.ts +++ b/scripts/sync-graphql.ts @@ -13,9 +13,8 @@ * 4. stops the server (try/finally — no orphan listeners). * * Flags: - * --generate-only No-op accepted for symmetry with scripts/sync-api.ts. - * This script is inherently generate-only: it always reads - * the local SDL, there is nothing to fetch. + * --generate-only No-op flag. This script is inherently generate-only: + * it always reads the local SDL, there is nothing to fetch. * --schema SDL file to read (default: bff/schema.graphql). * Used by QA to point at a broken copy without touching * the real schema. From 1ad7c0154589fa02d44777fa188c9ad8dd7e04c3 Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 14:51:01 +0900 Subject: [PATCH 15/17] fix(bff): sanitize nested field loader errors --- bff/resolvers.test.ts | 36 +++++++++++++++++++++++++++++++++++- bff/resolvers.ts | 4 ++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/bff/resolvers.test.ts b/bff/resolvers.test.ts index bf71c7d..5c7944a 100644 --- a/bff/resolvers.test.ts +++ b/bff/resolvers.test.ts @@ -9,7 +9,7 @@ import { makeSchema } from "./schema.ts"; import { resolvers } from "./resolvers.ts"; import { buildContext, type GraphQLContext } from "./context.ts"; import { createMockEmumetClient } from "./emumet/mock.ts"; -import type { EmumetClient } from "./emumet/client.ts"; +import { EmumetApiError, type EmumetClient } from "./emumet/client.ts"; import type { SessionAdapter } from "./session.ts"; const schema: GraphQLSchema = makeSchema(resolvers); @@ -232,3 +232,37 @@ describe("(b) UNAUTHENTICATED branch", () => { expect(result.errors?.[0]?.extensions?.code).toBe("UNAUTHENTICATED"); }); }); + +describe("(c) nested field loader error sanitization", () => { + test("Account.profile sanitizes EmumetApiError to INTERNAL_SERVER_ERROR", async () => { + // Given: client whose listProfiles throws EmumetApiError with an upstream body + const throwingClient: EmumetClient = { + ...createMockEmumetClient(), + listProfiles: () => Promise.reject(new EmumetApiError(500, "SECRET-UPSTREAM-BODY")), + }; + const context = await authenticatedContext(throwingClient); + // When: query with profile sub-field (triggers Account.profile resolver) + const result = await run('{ account(id: "acc_01") { id profile { nanoid } } }', context); + // Then: secret body must NOT leak, code must be INTERNAL_SERVER_ERROR + expect(result.errors).toBeDefined(); + expect(result.errors?.[0]?.message).not.toContain("SECRET-UPSTREAM-BODY"); + expect(result.errors?.[0]?.message).toBe("Internal server error"); + expect(result.errors?.[0]?.extensions?.code).toBe("INTERNAL_SERVER_ERROR"); + }); + + test("Account.metadata sanitizes EmumetApiError to INTERNAL_SERVER_ERROR", async () => { + // Given: client whose listMetadata throws EmumetApiError with an upstream body + const throwingClient: EmumetClient = { + ...createMockEmumetClient(), + listMetadata: () => Promise.reject(new EmumetApiError(500, "SECRET-UPSTREAM-BODY")), + }; + const context = await authenticatedContext(throwingClient); + // When: query with metadata sub-field (triggers Account.metadata resolver) + const result = await run('{ account(id: "acc_01") { id metadata { nanoid } } }', context); + // Then: secret body must NOT leak, code must be INTERNAL_SERVER_ERROR + expect(result.errors).toBeDefined(); + expect(result.errors?.[0]?.message).not.toContain("SECRET-UPSTREAM-BODY"); + expect(result.errors?.[0]?.message).toBe("Internal server error"); + expect(result.errors?.[0]?.extensions?.code).toBe("INTERNAL_SERVER_ERROR"); + }); +}); diff --git a/bff/resolvers.ts b/bff/resolvers.ts index ac95a6c..80d3eb5 100644 --- a/bff/resolvers.ts +++ b/bff/resolvers.ts @@ -53,9 +53,9 @@ export const resolvers = { }, Account: { profile: (parent: Account, _args: unknown, context: GraphQLContext) => - context.makeLoaders().profile.load(parent.id), + withEmumetErrors(() => context.makeLoaders().profile.load(parent.id)), metadata: (parent: Account, _args: unknown, context: GraphQLContext) => - context.makeLoaders().metadata.load(parent.id), + withEmumetErrors(() => context.makeLoaders().metadata.load(parent.id)), }, Mutation: { createAccount: (_parent: unknown, args: { input: CreateAccountInput }, context: GraphQLContext) => From 890104f3a0cae96530775239ef1aa8abef6af292 Mon Sep 17 00:00:00 2001 From: turtton Date: Mon, 20 Jul 2026 15:14:30 +0900 Subject: [PATCH 16/17] fix(ssr): serve theme-boot script as static file to avoid HTML escaping --- index.ts | 1 + src/App/View/Layout.purs | 6 ++++-- src/theme.js | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 src/theme.js diff --git a/index.ts b/index.ts index 0e6d8c8..bdbfc2d 100644 --- a/index.ts +++ b/index.ts @@ -160,6 +160,7 @@ async function pkceChallenge(verifier: string): Promise { const staticFiles: Record = { "/app.js": { path: "dist/app.js", contentType: "application/javascript" }, "/style.css": { path: "dist/style.css", contentType: "text/css" }, + "/theme.js": { path: "src/theme.js", contentType: "application/javascript" }, }; function serveStatic(pathname: string): Response | null { diff --git a/src/App/View/Layout.purs b/src/App/View/Layout.purs index c12e480..183f1e0 100644 --- a/src/App/View/Layout.purs +++ b/src/App/View/Layout.purs @@ -70,7 +70,9 @@ document content = ] ] +-- | Served as a static file (src/theme.js) because Flame's renderer HTML-escapes +-- | HE.text payloads, and