diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..689f63d6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +## Contract or migration changes + +Complete this section only when the pull request changes an HTTP contract or PocketBase migration. + +- [ ] Migration updated (if persistent storage changed) +- [ ] Generated artifacts refreshed with `bun run generate` +- [ ] Consumer verified diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 00000000..14c4aaf7 --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,29 @@ +name: Contract pipeline + +on: + pull_request: + push: + branches: [dev, release] + +jobs: + generated-artifacts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.8 + - run: bun install --frozen-lockfile + - run: bun run generate + - run: git diff --exit-code -- apps/web/public/openapi.json apps/web/src/lib/api packages/api-types/src/openapi.ts + + contract-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.8 + - run: bun install --frozen-lockfile + - run: bun run check + - run: bun run test:contract diff --git a/README.md b/README.md index 650a4128..cc638ee3 100755 --- a/README.md +++ b/README.md @@ -34,67 +34,7 @@ bun run dev The PocketBase backend can be either run using the aio container (default method) or connected to an external database (not recommended, migrations may fail) ## Configuration -You can use the following environment variables for the all-in-one container - default values will also work: - -### Core Settings -| Name | Required | Default Value | Description | -| --- | --- | --- | --- | -| INSTANCE_NAME / NEXT_PUBLIC_INSTANCE_NAME | No | Dashwise | The dashboard's display name | -| PB_URL / NEXT_PUBLIC_PB_URL | No (if start pocketbase is true) | `http://127.0.0.1:8090` | PocketBase URL. Backend uses `PB_URL`, frontend uses `NEXT_PUBLIC_PB_URL` | -| START_POCKETBASE | No | `true` | Start the bundled PocketBase process; set to `false` to use an external instance | -| PB_BINARY_PATH | No | - | Path to PocketBase binary (default: `pocketbase/pocketbase`) | -| PORT | No | `3000` | HTTP port for the backend server | - -### Authentication -| Name | Required | Default Value | Description | -| --- | --- | --- | --- | -| PB_ADMIN_EMAIL | Yes | `default@dashwise.local` | Email of the PocketBase admin user | -| PB_ADMIN_PASSWORD | Yes | `DashwiseIsAwesome` | Password of the PocketBase admin user | - -### URLs -| Name | Required | Default Value | Description | -| --- | --- | --- | --- | -| NEXT_PUBLIC_APP_URL / APP_BASE_URL | No | `http://localhost:3000` | Public URL of the application | -| NEXT_PUBLIC_BACKEND_URL | No | - | Backend URL for frontend API calls (fallback: window.location.origin in production) | -| DASHWISE_URL | No | - | Internal Dashwise URL for jobs container communication | - -### Appearance & Features -| Name | Required | Default Value | Description | -| --- | --- | --- | --- | -| NEXT_PUBLIC_DEFAULT_BG_URL / DEFAULT_BG_URL | No | `/dashboard-wallpaper.png` | Default background URL for new users | -| NEXT_PUBLIC_ENABLE_SSO / ENABLE_SSO | No | `false` | Enable Single Sign-On (SSO) via OIDC | -| NEXT_PUBLIC_DISABLE_USER_SIGNUP / DISABLE_USER_SIGNUP | No | `false` | Disable user self-registration | - -### SSL/TLS -| Name | Required | Default Value | Description | -| --- | --- | --- | --- | -| NEXT_PUBLIC_INTEGRATIONS_ENABLE_SSL / ALLOW_INSECURE_CERTS_FOR_INTEGRATION_URLS | No | `false` | Allow insecure SSL certificates for integration URLs | -| ALLOW_SSL | No | `false` | Enable SSL for internal service communication | -| LOG_LEVEL / BACKEND_LOG_LEVEL | No | - | Backend log level (debug, info, warn, error) | - -### Jobs & Background Processing -| Name | Required | Default Value | Description | -| --- | --- | --- | --- | -| JOBS_URL / NEXT_PUBLIC_JOBS_URL | No | `http://127.0.0.1:3001` | URL of the jobs service | -| JOBS_WEBHOOK_ENABLE / NEXT_PUBLIC_JOBS_WEBHOOK_ENABLE | No | `false` | Explicitly enable the jobs webhook. Set to `1` or `true` to force-enable | -| JOBS_WEBHOOK_URL | No | `http://jobs:3000/api/forward-notifications` | Webhook URL for forwarding notifications to jobs | -| JOBS_MONITORING_RETRY_AFTER | No | `5000` | Time in milliseconds to wait before retrying a failed monitoring ping | - -### Scheduled Jobs (Cron Expressions) -| Name | Required | Default Value | Description | -| --- | --- | --- | --- | -| SEARCHITEMS_SCHEDULE | No | `*/10 * * * *` | Interval for search item indexing job | -| ENABLE_ICONS_REFRESH | No | `false` | Enable automatic icon refresh job | -| PULL_ICONS_SCHEDULE | No | `0 */6 * * *` | How often the icons refresh job runs | -| MONITORING_INDEXER_SCHEDULE | No | `*/10 * * * *` | How often the monitoring indexer runs | -| MONITORING_RUNNER_SCHEDULE | No | `*/1 * * * *` | How often the monitoring runner executes | -| UPDATE_CHECK_SCHEDULE | No | `0 2 * * *` | Schedule for update check job | -| FEED_BUILDING_SCHEDULE | No | `*/30 * * * *` | Schedule for news feed building job | -| NOTIFICATION_FORWARDER_SCHEDULE | No | `* * * * *` | Schedule for notification forwarder job | -| DEFAULT_INTEGRATIONS_SCHEDULE | No | `0 4 * * *` | Schedule for default integrations sync | -| PAGECONFIG_CLEANUP_SCHEDULE | No | `0 5 * * *` | Schedule for page config cleanup | -| MONITORING_OUTLIER_THRESHOLD_TYPE | No | `relative` | Threshold type for monitoring outliers (`absolute` or `relative`) | -| MONITORING_OUTLIER_THRESHOLD_VALUE | No | `50` | Threshold value for monitoring outliers | +See the [configuration reference](docs/Configuration.md) for all supported environment variables. ## Tech Stack Frontend: React SPA bundled with Bun diff --git a/apps/backend/openapi.yaml b/apps/backend/openapi.yaml index 0e4a1028..3142d001 100644 --- a/apps/backend/openapi.yaml +++ b/apps/backend/openapi.yaml @@ -23,6 +23,8 @@ tags: - name: widgets - name: weather paths: + # DO NOT EDIT — generated by bun run generate from system.contract.ts + # BEGIN GENERATED SYSTEM CONTRACTS /appConfig: get: tags: @@ -30,7 +32,12 @@ paths: summary: Get application config responses: "200": - $ref: "#/components/responses/JsonOk" + description: OK + content: + application/json: + schema: + type: object + additionalProperties: {} /appInfo: get: tags: @@ -38,7 +45,24 @@ paths: summary: Get application info responses: "200": - $ref: "#/components/responses/JsonOk" + description: OK + content: + application/json: + schema: + type: object + properties: + updateAvailable: + type: boolean + currentAppVersion: + type: string + userSignupDisabled: + type: boolean + required: + - updateAvailable + - currentAppVersion + - userSignupDisabled + additionalProperties: false + # END GENERATED SYSTEM CONTRACTS /auth/callback: get: tags: diff --git a/apps/backend/package.json b/apps/backend/package.json index 44b9a5cd..f9dad4c8 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,7 +11,8 @@ "scripts": { "dev": "bun --hot src/index.ts", "start": "NODE_ENV=production bun src/index.ts", - "generate:openapi": "bun ../../scripts/generate-openapi-json.ts && bunx openapi-typescript openapi.yaml -o ../../packages/api-types/src/openapi.ts", + "generate": "bun --cwd ../.. run generate", + "generate:openapi": "bun run generate", "build": "bunx -p typescript@5.9.3 tsc -p tsconfig.json", "lint": "eslint . --quiet", "lint:all": "eslint ." diff --git a/apps/backend/src/features/system/system.contract.ts b/apps/backend/src/features/system/system.contract.ts new file mode 100644 index 00000000..ae9d51c6 --- /dev/null +++ b/apps/backend/src/features/system/system.contract.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +import { defineGetContract, emptyQuerySchema } from "../../lib/http/contract"; + +export const appConfigContract = defineGetContract({ + summary: "Get application config", + tags: ["app"], + query: emptyQuerySchema, + response: z.record(z.unknown()), +}); + +export const appInfoContract = defineGetContract({ + summary: "Get application info", + tags: ["app"], + query: emptyQuerySchema, + response: z.object({ + updateAvailable: z.boolean(), + currentAppVersion: z.string(), + userSignupDisabled: z.boolean(), + }), +}); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 3dce74a5..d68643f8 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -55,7 +55,9 @@ app.use("*", async (c, next) => { await next(); } finally { const processingTime = Math.round(performance.now() - startedAt); - logger.info(`[${timestamp}] ${c.req.method} ${c.req.path} ${processingTime}ms`); + if (config.LOG_LEVEL?.trim().toLowerCase() === "info") { + logger.info(`[${timestamp}] ${c.req.method} ${c.req.path} ${processingTime}ms`); + } } }); diff --git a/apps/backend/src/lib/http/contract.ts b/apps/backend/src/lib/http/contract.ts new file mode 100644 index 00000000..617533f1 --- /dev/null +++ b/apps/backend/src/lib/http/contract.ts @@ -0,0 +1,34 @@ +import { z, type ZodTypeAny } from "zod"; + +export type GetContract = { + summary: string; + tags: string[]; + query: ZodTypeAny; + response: Response; +}; + +export function defineGetContract( + contract: GetContract, +) { + return contract; +} + +export function shouldValidateContractResponses() { + return Bun.env.NODE_ENV !== "production"; +} + +export function validateContractResponse( + contract: GetContract, + value: unknown, +) { + return shouldValidateContractResponses() ? contract.response.parse(value) : value; +} + +export function validateContractQuery( + contract: GetContract, + value: unknown, +) { + return contract.query.parse(value); +} + +export const emptyQuerySchema = z.object({}).strict(); diff --git a/apps/backend/src/routes/shared.ts b/apps/backend/src/routes/shared.ts index e180f48d..662c4e06 100644 --- a/apps/backend/src/routes/shared.ts +++ b/apps/backend/src/routes/shared.ts @@ -6,6 +6,12 @@ import type { Context } from "hono"; import { ApiActionError, requireUserAuth } from "../lib/data/auth"; import { z } from "zod"; +import { + type GetContract, + validateContractQuery, + validateContractResponse, +} from "../lib/http/contract"; + import { config } from "../lib/config"; import { defaultHomeConfig } from "@dashwise/assets"; @@ -77,6 +83,17 @@ export function withJson) = imp }; } +/** Validate request input at the route boundary and responses outside production. */ +export function withGetContract( + contract: GetContract, + handler: (c: Context) => Promise | unknown, +) { + return withJson(async (c) => { + validateContractQuery(contract, c.req.query()); + return validateContractResponse(contract, await handler(c)); + }); +} + export function readAuthToken(c: Context) { const authorization = c.req.header("authorization") ?? c.req.header("Authorization"); if (authorization?.toLowerCase().startsWith("bearer ")) { @@ -111,4 +128,4 @@ export function routeRedirectTarget(c: Context) { } return config.DASHWISE_URL; -} \ No newline at end of file +} diff --git a/apps/backend/src/routes/system.route.ts b/apps/backend/src/routes/system.route.ts index fde6c3b9..cb4ea1de 100644 --- a/apps/backend/src/routes/system.route.ts +++ b/apps/backend/src/routes/system.route.ts @@ -5,12 +5,16 @@ import { getLocations } from "../lib/data/misc"; import { runPullIcons } from "../lib/data/jobs"; import { jobsApi, validateJobsBasicAuth } from "../jobs/index"; -import { readAuthToken, requireAuth, withJson } from "./shared"; +import { readAuthToken, requireAuth, withGetContract, withJson } from "./shared"; +import { + appConfigContract, + appInfoContract, +} from "../features/system/system.contract"; const systemRoute = new Hono(); -systemRoute.get("/api/v1/appConfig", withJson(() => getAppConfig())); -systemRoute.get("/api/v1/appInfo", withJson(() => getAppInfo())); +systemRoute.get("/api/v1/appConfig", withGetContract(appConfigContract, () => getAppConfig())); +systemRoute.get("/api/v1/appInfo", withGetContract(appInfoContract, () => getAppInfo())); systemRoute.get( "/api/v1/locations", diff --git a/apps/web/package.json b/apps/web/package.json index f6bdd92d..56cce59e 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,13 +4,14 @@ "private": true, "scripts": { "dev": "bunx vite --host 0.0.0.0 --port 5173", - "build": "bun run generate:openapi && bun run generate:api-sdk && bunx vite build", + "build": "bunx vite build", "start": "bunx vite preview --host 0.0.0.0 --port 4173", "lint": "eslint . --quiet", "lint:all": "eslint .", - "generate:openapi": "bun ../../scripts/generate-openapi-json.ts && bunx openapi-typescript ../../apps/backend/openapi.yaml -o ../../packages/api-types/src/openapi.ts", - "generate:api-sdk": "bunx openapi-ts -f openapi-ts.config.ts", - "generate:api-types": "bun run generate:openapi" + "generate": "bun --cwd ../.. run generate", + "generate:openapi": "bun run generate", + "generate:api-sdk": "bun run generate", + "generate:api-types": "bun run generate" }, "dependencies": { "@dashwise/api-types": "workspace:*", diff --git a/apps/web/public/openapi.json b/apps/web/public/openapi.json index e77aa1ff..be16c9b9 100644 --- a/apps/web/public/openapi.json +++ b/apps/web/public/openapi.json @@ -69,7 +69,15 @@ "summary": "Get application config", "responses": { "200": { - "$ref": "#/components/responses/JsonOk" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + } } } } @@ -82,7 +90,31 @@ "summary": "Get application info", "responses": { "200": { - "$ref": "#/components/responses/JsonOk" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "updateAvailable": { + "type": "boolean" + }, + "currentAppVersion": { + "type": "string" + }, + "userSignupDisabled": { + "type": "boolean" + } + }, + "required": [ + "updateAvailable", + "currentAppVersion", + "userSignupDisabled" + ], + "additionalProperties": false + } + } + } } } } diff --git a/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx b/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx index feb9a727..329bc3ee 100644 --- a/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx +++ b/apps/web/src/components/dashboard/DashboardLayoutTemplate.tsx @@ -517,16 +517,19 @@ export default function DashboardLayoutTemplate({ } else if (typeof h === "number") { heightStyle = `${h}px`; } - return renderWidgetMenuWrapper({ - baseKey, - wrapperClass, - style: heightStyle ? { height: heightStyle } : undefined, - children: renderWidget({ - type: "placeholder", - params: cfg.params, - className: "h-full w-full", - }), - }); + return ( +
+ {renderWidget({ + type: "placeholder", + params: cfg.params, + className: "h-full w-full", + })} +
+ ); } case "main-clock": { const ref = getHeightRefCallback("main-clock"); diff --git a/apps/web/src/components/dashboard/Screensaver.tsx b/apps/web/src/components/dashboard/Screensaver.tsx index b80e09fd..8221ed07 100644 --- a/apps/web/src/components/dashboard/Screensaver.tsx +++ b/apps/web/src/components/dashboard/Screensaver.tsx @@ -7,6 +7,8 @@ import { renderWidget } from "../widgets/Widget"; import AppIcon from "@dashwise/app-icon"; import { fetchWallpaperBlob } from "@/lib/apiClient"; +const TOUCH_CONTROLS_HIDE_DELAY = 2500; + export default function Screensaver( { active, onExit }: { active: boolean; onExit: () => void }, ) { @@ -20,6 +22,28 @@ export default function Screensaver( const [frameBackgrounds, setFrameBackgrounds] = useState>({}); const [scrollLeft, setScrollLeft] = useState(0); const scrollRef = useRef(null); + const controlsHideTimeoutRef = useRef | null>(null); + + const clearControlsHideTimeout = () => { + if (controlsHideTimeoutRef.current) { + clearTimeout(controlsHideTimeoutRef.current); + controlsHideTimeoutRef.current = null; + } + }; + + const scheduleTouchControlsHide = () => { + clearControlsHideTimeout(); + controlsHideTimeoutRef.current = setTimeout(() => { + setIsHovering(false); + controlsHideTimeoutRef.current = null; + }, TOUCH_CONTROLS_HIDE_DELAY); + }; + + useEffect(() => () => { + if (controlsHideTimeoutRef.current) { + clearTimeout(controlsHideTimeoutRef.current); + } + }, []); useEffect(() => { fetch("/fonts/index.json") @@ -192,8 +216,29 @@ export default function Screensaver(
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} + onMouseLeave={() => { + clearControlsHideTimeout(); + setIsHovering(false); + }} onMouseMove={() => setIsHovering(true)} + onPointerEnter={(event) => { + if (event.pointerType === "touch") { + setIsHovering(true); + scheduleTouchControlsHide(); + } + }} + onPointerDown={(event) => { + if (event.pointerType === "touch") { + setIsHovering(true); + scheduleTouchControlsHide(); + } + }} + onPointerMove={(event) => { + if (event.pointerType === "touch") { + setIsHovering(true); + scheduleTouchControlsHide(); + } + }} >
extends SerializePrimitiveOptions, SerializerOptions {} diff --git a/apps/web/src/lib/api/core/queryKeySerializer.gen.ts b/apps/web/src/lib/api/core/queryKeySerializer.gen.ts index 773b0650..5453b27f 100644 --- a/apps/web/src/lib/api/core/queryKeySerializer.gen.ts +++ b/apps/web/src/lib/api/core/queryKeySerializer.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts /** diff --git a/apps/web/src/lib/api/core/serverSentEvents.gen.ts b/apps/web/src/lib/api/core/serverSentEvents.gen.ts index ddf3c4d1..a1db60b5 100644 --- a/apps/web/src/lib/api/core/serverSentEvents.gen.ts +++ b/apps/web/src/lib/api/core/serverSentEvents.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts import type { Config } from './types.gen'; diff --git a/apps/web/src/lib/api/core/types.gen.ts b/apps/web/src/lib/api/core/types.gen.ts index c657c859..48ca2109 100644 --- a/apps/web/src/lib/api/core/types.gen.ts +++ b/apps/web/src/lib/api/core/types.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts import type { Auth, AuthToken } from './auth.gen'; diff --git a/apps/web/src/lib/api/core/utils.gen.ts b/apps/web/src/lib/api/core/utils.gen.ts index af56e071..ca818a65 100644 --- a/apps/web/src/lib/api/core/utils.gen.ts +++ b/apps/web/src/lib/api/core/utils.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index 16255397..ae5e1b3c 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts export { deleteAuthDeleteAccount, deleteIntegrationsById, deleteLinksItemsByLinkId, deleteMonitorsById, deleteNotificationsForwarders, deleteNotificationsTopics, deleteNotificationsTopicTokens, getAppConfig, getAppInfo, getAuthCallback, getAuthSso, getGlanceables, getGlanceablesByIntegration, getIntegrations, getIntegrationsCaldavEvents, getIntegrationsConsumerData, getIntegrationsWidgetProperties, getJobsPullIcons, getJobsSearchItems, getLinksCollections, getLinksFolders, getLinksHome, getLinksHomeGroups, getLinksItems, getLinksTags, getLocations, getMonitoringStatus, getMonitors, getMonitorsById, getNews, getNewsFeed, getNewsFeedMetadata, getNewsFeedRecordsById, getNewsFeedRefresh, getNewsFeeds, getNewsFeedsById, getNewsSubscriptions, getNewsSubscriptionsByIdJson, getNotifications, getNotificationsForwarders, getNotificationsTopics, getNotificationsTopicTokens, getPageConfig, getPageConfigUserPages, getSearchItems, getSearchItemsFrequentlyUsed, getTestBookmarks, getWallpapers, getWeather, getWidgets, getWidgetsByIntegration, getWidgetsGlanceable, getWidgetsGlanceables, type Options, patchAuthUpdateUserProperty, postAuthChangePassword, postAuthLogin, postAuthMfa, postAuthSignup, postAuthValidateAuth, postIntegrations, postIntegrationsConsumerData, postIntegrationsProxyAction, postIntegrationsTestEndpoint, postLinksCollections, postLinksFolders, postLinksHomeGroups, postLinksItems, postLinksReorder, postLinksTags, postMonitoringStatus, postMonitors, postNewsFeedRecords, postNewsFeedRecordsById, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFixMissingTitles, postNotifications, postNotificationsByTopic, postNotificationsForwarders, postNotificationsMarkAsRead, postNotificationsTest, postNotificationsTopics, postNotificationsTopicTokens, postPageConfigHome, postPageConfigIntegrationData, postPageConfigMigrateLegacy, postSearchItemsUsageStats, postWallpapers, putIntegrationsById, putLinksCollectionsByCollectionId, putLinksFoldersByFolderIdIcon, putLinksItemsByLinkId, putLinksTagsByTagId, putMonitorsById, putNotificationsForwarders, putPageConfig } from './sdk.gen'; diff --git a/apps/web/src/lib/api/sdk.gen.ts b/apps/web/src/lib/api/sdk.gen.ts index 7c91bbed..29f19dd6 100644 --- a/apps/web/src/lib/api/sdk.gen.ts +++ b/apps/web/src/lib/api/sdk.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; diff --git a/apps/web/src/lib/api/types.gen.ts b/apps/web/src/lib/api/types.gen.ts index cea47460..cfe18787 100644 --- a/apps/web/src/lib/api/types.gen.ts +++ b/apps/web/src/lib/api/types.gen.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { @@ -25,7 +26,9 @@ export type GetAppConfigResponses = { /** * OK */ - 200: GenericObject; + 200: { + [key: string]: unknown; + }; }; export type GetAppConfigResponse = GetAppConfigResponses[keyof GetAppConfigResponses]; @@ -41,7 +44,11 @@ export type GetAppInfoResponses = { /** * OK */ - 200: GenericObject; + 200: { + updateAvailable: boolean; + currentAppVersion: string; + userSignupDisabled: boolean; + }; }; export type GetAppInfoResponse = GetAppInfoResponses[keyof GetAppInfoResponses]; diff --git a/docs/Configuration.md b/docs/Configuration.md new file mode 100644 index 00000000..411e6283 --- /dev/null +++ b/docs/Configuration.md @@ -0,0 +1,70 @@ +# Configuration + +You can use the following environment variables for the all-in-one container; the default values also work. + +### Core Settings + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| INSTANCE_NAME / NEXT_PUBLIC_INSTANCE_NAME | No | Dashwise | The dashboard's display name | +| PB_URL / NEXT_PUBLIC_PB_URL | No (if start pocketbase is true) | `http://127.0.0.1:8090` | PocketBase URL. Backend uses `PB_URL`, frontend uses `NEXT_PUBLIC_PB_URL` | +| START_POCKETBASE | No | `true` | Start the bundled PocketBase process; set to `false` to use an external instance | +| PB_BINARY_PATH | No | - | Path to PocketBase binary (default: `pocketbase/pocketbase`) | +| PORT | No | `3000` | HTTP port for the backend server | + +### Authentication + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| PB_ADMIN_EMAIL | Yes | `default@dashwise.local` | Email of the PocketBase admin user | +| PB_ADMIN_PASSWORD | Yes | `DashwiseIsAwesome` | Password of the PocketBase admin user | + +### URLs + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| NEXT_PUBLIC_APP_URL / APP_BASE_URL | No | `http://localhost:3000` | Public URL of the application | +| NEXT_PUBLIC_BACKEND_URL | No | - | Backend URL for frontend API calls (fallback: window.location.origin in production) | +| DASHWISE_URL | No | - | Internal Dashwise URL for jobs container communication | + +### Appearance & Features + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| NEXT_PUBLIC_DEFAULT_BG_URL / DEFAULT_BG_URL | No | `/dashboard-wallpaper.png` | Default background URL for new users | +| NEXT_PUBLIC_ENABLE_SSO / ENABLE_SSO | No | `false` | Enable Single Sign-On (SSO) via OIDC | +| NEXT_PUBLIC_DISABLE_USER_SIGNUP / DISABLE_USER_SIGNUP | No | `false` | Disable user self-registration | + +### SSL/TLS + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| NEXT_PUBLIC_INTEGRATIONS_ENABLE_SSL / ALLOW_INSECURE_CERTS_FOR_INTEGRATION_URLS | No | `false` | Allow insecure SSL certificates for integration URLs | +| ALLOW_SSL | No | `false` | Enable SSL for internal service communication | +| LOG_LEVEL / BACKEND_LOG_LEVEL | No | - | Backend log level (debug, info, warn, error) | + +### Jobs & Background Processing + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| JOBS_URL / NEXT_PUBLIC_JOBS_URL | No | `http://127.0.0.1:3001` | URL of the jobs service | +| JOBS_WEBHOOK_ENABLE / NEXT_PUBLIC_JOBS_WEBHOOK_ENABLE | No | `false` | Explicitly enable the jobs webhook. Set to `1` or `true` to force-enable | +| JOBS_WEBHOOK_URL | No | `http://jobs:3000/api/forward-notifications` | Webhook URL for forwarding notifications to jobs | +| JOBS_MONITORING_RETRY_AFTER | No | `5000` | Time in milliseconds to wait before retrying a failed monitoring ping | + +### Scheduled Jobs (Cron Expressions) + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| SEARCHITEMS_SCHEDULE | No | `*/10 * * * *` | Interval for search item indexing job | +| ENABLE_ICONS_REFRESH | No | `false` | Enable automatic icon refresh job | +| PULL_ICONS_SCHEDULE | No | `0 */6 * * *` | How often the icons refresh job runs | +| MONITORING_INDEXER_SCHEDULE | No | `*/10 * * * *` | How often the monitoring indexer runs | +| MONITORING_RUNNER_SCHEDULE | No | `*/1 * * * *` | How often the monitoring runner executes | +| UPDATE_CHECK_SCHEDULE | No | `0 2 * * *` | Schedule for update check job | +| FEED_BUILDING_SCHEDULE | No | `*/30 * * * *` | Schedule for news feed building job | +| NOTIFICATION_FORWARDER_SCHEDULE | No | `* * * * *` | Schedule for notification forwarder job | +| DEFAULT_INTEGRATIONS_SCHEDULE | No | `0 4 * * *` | Schedule for default integrations sync | +| PAGECONFIG_CLEANUP_SCHEDULE | No | `0 5 * * *` | Schedule for page config cleanup | +| MONITORING_OUTLIER_THRESHOLD_TYPE | No | `relative` | Threshold type for monitoring outliers (`absolute` or `relative`) | +| MONITORING_OUTLIER_THRESHOLD_VALUE | No | `50` | Threshold value for monitoring outliers | diff --git a/docs/Dev.md b/docs/Dev.md new file mode 100644 index 00000000..bccd5514 --- /dev/null +++ b/docs/Dev.md @@ -0,0 +1,33 @@ + +## Generated artifacts + +Generated artifacts are committed so the checked-in frontend can consume the API +contract without running generators. Regenerate all of them with: + +```sh +bun run generate +``` + +Do not edit generated TypeScript files directly; they are marked with a +`DO NOT EDIT` header. `bun run build` only builds and never regenerates files. + +| Output | Authoritative input | Generated by | +| --- | --- | --- | +| `apps/web/public/openapi.json` | `apps/backend/openapi.yaml` | `bun run generate` | +| `packages/api-types/src/openapi.ts` | `apps/backend/openapi.yaml` | `bun run generate` | +| `apps/web/src/lib/api/**` | `apps/backend/openapi.yaml` and `apps/web/openapi-ts.config.ts` | `bun run generate` | + +`openapi.json` is JSON and therefore cannot carry a comment header. The +PocketBase schema authority is `pocketbase/migrations`; generated PocketBase +record types have not yet been introduced, so storage records must stay behind +backend mapping code and must not be exposed through HTTP routes. + +New or materially changed backend endpoints should use a feature-local Zod +contract. The migrated system endpoints demonstrate the pattern in +`apps/backend/src/features/system/system.contract.ts`: the route validates its +input at the Hono boundary, validates responses outside production, and +`bun run generate` writes the marked OpenAPI section from that schema. + +Before opening a contract change, run `bun run generate && git diff --exit-code` +followed by `bun run check`. `bun run test:contract` validates the checked-in +OpenAPI contract independently of the generated artifacts. \ No newline at end of file diff --git a/package.json b/package.json index 184b44d5..9b689e6a 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,19 @@ "dev:testenv": "./scripts/dev-testenv.sh", "dev:backend": "bun --cwd apps/backend --hot src/index.ts", "dev:web": "cd apps/web && bunx dotenv-cli -e ../../.env -- bunx vite --host 0.0.0.0 --port 5173", - "generate:openapi": "bun scripts/generate-openapi-json.ts && bunx openapi-typescript apps/backend/openapi.yaml -o packages/api-types/src/openapi.ts", + "generate": "bun run generate:openapi-document && bun run generate:api-types && bun run generate:api-sdk && bun run mark-generated", + "generate:openapi-document": "bun scripts/generate-openapi-contracts.ts && bun scripts/generate-openapi-json.ts", + "generate:api-types": "cd apps/web && bunx openapi-typescript ../../apps/backend/openapi.yaml -o ../../packages/api-types/src/openapi.ts", "generate:api-sdk": "cd apps/web && bunx openapi-ts -f openapi-ts.config.ts", + "generate:openapi": "bun run generate", + "mark-generated": "bun scripts/mark-generated-artifacts.ts", "build": "bun run --workspaces --if-present build", "lint": "eslint . --quiet", "lint:all": "eslint .", - "typecheck": "bun run --workspaces --if-present typecheck" + "typecheck": "bun run --workspaces --if-present typecheck", + "test": "bun test", + "test:contract": "bun scripts/contract-smoke.ts", + "check:generated": "bun run generate && git diff --exit-code -- apps/web/public/openapi.json apps/web/src/lib/api packages/api-types/src/openapi.ts", + "check": "bun run lint && bun run typecheck && bun run check:generated" } } diff --git a/packages/api-types/src/openapi.ts b/packages/api-types/src/openapi.ts index 7e8312c8..3ecf9ccc 100644 --- a/packages/api-types/src/openapi.ts +++ b/packages/api-types/src/openapi.ts @@ -1,3 +1,4 @@ +// DO NOT EDIT — generated by bun run generate /** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. @@ -21,7 +22,17 @@ export interface paths { }; requestBody?: never; responses: { - 200: components["responses"]["JsonOk"]; + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; }; }; put?: never; @@ -49,7 +60,19 @@ export interface paths { }; requestBody?: never; responses: { - 200: components["responses"]["JsonOk"]; + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + updateAvailable: boolean; + currentAppVersion: string; + userSignupDisabled: boolean; + }; + }; + }; }; }; put?: never; diff --git a/scripts/contract-smoke.ts b/scripts/contract-smoke.ts new file mode 100644 index 00000000..e7f1a48a --- /dev/null +++ b/scripts/contract-smoke.ts @@ -0,0 +1,21 @@ +import YAML from "yaml"; + +const specificationPath = new URL("../apps/backend/openapi.yaml", import.meta.url); +const specification = YAML.parse(await Bun.file(specificationPath).text()); + +if ( + !specification || + typeof specification !== "object" || + typeof specification.openapi !== "string" || + !specification.info || + typeof specification.info.title !== "string" || + typeof specification.info.version !== "string" || + !specification.paths || + typeof specification.paths !== "object" +) { + throw new Error("OpenAPI contract smoke test failed: invalid apps/backend/openapi.yaml"); +} + +console.log( + `OpenAPI contract smoke test passed (${Object.keys(specification.paths).length} paths).`, +); diff --git a/scripts/generate-openapi-contracts.ts b/scripts/generate-openapi-contracts.ts new file mode 100644 index 00000000..f171e50f --- /dev/null +++ b/scripts/generate-openapi-contracts.ts @@ -0,0 +1,92 @@ +import YAML from "yaml"; + +import { + appConfigContract, + appInfoContract, +} from "../apps/backend/src/features/system/system.contract"; +import type { GetContract } from "../apps/backend/src/lib/http/contract"; + +const sourcePath = new URL("../apps/backend/openapi.yaml", import.meta.url); +const beginMarker = " # BEGIN GENERATED SYSTEM CONTRACTS"; +const endMarker = " # END GENERATED SYSTEM CONTRACTS"; + +function zodToOpenApiSchema(schema: any): Record { + const typeName = schema?._def?.typeName; + + switch (typeName) { + case "ZodString": + return { type: "string" }; + case "ZodBoolean": + return { type: "boolean" }; + case "ZodNumber": + return { type: "number" }; + case "ZodUnknown": + case "ZodAny": + return {}; + case "ZodRecord": + return { + type: "object", + additionalProperties: zodToOpenApiSchema(schema._def.valueType), + }; + case "ZodObject": { + const shape = schema._def.shape(); + return { + type: "object", + properties: Object.fromEntries( + Object.entries(shape).map(([key, value]) => [key, zodToOpenApiSchema(value)]), + ), + required: Object.keys(shape), + additionalProperties: schema._def.unknownKeys === "passthrough", + }; + } + default: + throw new Error(`Unsupported Zod type in OpenAPI adapter: ${typeName}`); + } +} + +function toOpenApiPath(contract: GetContract) { + return { + get: { + tags: contract.tags, + summary: contract.summary, + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: zodToOpenApiSchema(contract.response), + }, + }, + }, + }, + }, + }; +} + +const generatedPaths = { + "/appConfig": toOpenApiPath(appConfigContract), + "/appInfo": toOpenApiPath(appInfoContract), +}; +const pathsYaml = YAML.stringify(generatedPaths) + .trimEnd() + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); +const generatedBlock = [ + " # DO NOT EDIT — generated by bun run generate from system.contract.ts", + beginMarker, + pathsYaml, + endMarker, +].join("\n"); + +const source = await Bun.file(sourcePath).text(); +const generatedBlockPattern = / {2}# DO NOT EDIT — (?: {2}# DO NOT EDIT — )*generated by bun run generate from system\.contract\.ts\n {2}# BEGIN GENERATED SYSTEM CONTRACTS[\s\S]*?\n {2}# END GENERATED SYSTEM CONTRACTS/; + +if (!generatedBlockPattern.test(source)) { + throw new Error("OpenAPI generated-system-contract markers are missing or malformed."); +} + +await Bun.write( + sourcePath, + source.replace(generatedBlockPattern, generatedBlock), +); diff --git a/scripts/generate-openapi-json.ts b/scripts/generate-openapi-json.ts index 6fcbf1ea..b7167a9e 100644 --- a/scripts/generate-openapi-json.ts +++ b/scripts/generate-openapi-json.ts @@ -6,4 +6,19 @@ const outputPath = new URL("../apps/web/public/openapi.json", import.meta.url); const source = await Bun.file(sourcePath).text(); const document = YAML.parse(source); +if ( + !document || + typeof document !== "object" || + typeof document.openapi !== "string" || + !document.info || + typeof document.info.title !== "string" || + typeof document.info.version !== "string" || + !document.paths || + typeof document.paths !== "object" +) { + throw new Error( + "apps/backend/openapi.yaml is not a valid OpenAPI document: openapi, info.title, info.version, and paths are required.", + ); +} + await Bun.write(outputPath, `${JSON.stringify(document, null, "\t")}\n`); diff --git a/scripts/mark-generated-artifacts.ts b/scripts/mark-generated-artifacts.ts new file mode 100644 index 00000000..3e3d175f --- /dev/null +++ b/scripts/mark-generated-artifacts.ts @@ -0,0 +1,31 @@ +import { readdir } from "node:fs/promises"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dir, ".."); +const header = "// DO NOT EDIT — generated by bun run generate\n"; +const generatedTypeScriptPaths = [ + resolve(root, "packages/api-types/src/openapi.ts"), +]; + +async function collectTypeScriptFiles(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + await collectTypeScriptFiles(path); + } else if (entry.isFile() && entry.name.endsWith(".ts")) { + generatedTypeScriptPaths.push(path); + } + } +} + +await collectTypeScriptFiles(resolve(root, "apps/web/src/lib/api")); + +for (const path of generatedTypeScriptPaths) { + const file = Bun.file(path); + const contents = await file.text(); + const marked = contents.startsWith(header) ? contents : `${header}${contents}`; + + if (marked !== contents) { + await Bun.write(path, marked); + } +} diff --git a/test/contract-smoke.test.ts b/test/contract-smoke.test.ts new file mode 100644 index 00000000..bc2163fc --- /dev/null +++ b/test/contract-smoke.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test"; +import YAML from "yaml"; + +test("the checked-in OpenAPI contract has its required document fields", async () => { + const specificationPath = new URL("../apps/backend/openapi.yaml", import.meta.url); + const specification = YAML.parse(await Bun.file(specificationPath).text()); + + expect(specification).toBeObject(); + expect(specification.openapi).toBeString(); + expect(specification.info?.title).toBeString(); + expect(specification.info?.version).toBeString(); + expect(specification.paths).toBeObject(); + expect(Object.keys(specification.paths).length).toBeGreaterThan(0); + expect(specification.paths["/appInfo"].get.responses["200"].content["application/json"].schema.properties) + .toEqual({ + updateAvailable: { type: "boolean" }, + currentAppVersion: { type: "string" }, + userSignupDisabled: { type: "boolean" }, + }); +}); diff --git a/test/system-contract.test.ts b/test/system-contract.test.ts new file mode 100644 index 00000000..594be663 --- /dev/null +++ b/test/system-contract.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; + +import { + appConfigContract, + appInfoContract, +} from "../apps/backend/src/features/system/system.contract"; +import { + validateContractQuery, + validateContractResponse, +} from "../apps/backend/src/lib/http/contract"; + +test("system contract rejects unexpected query parameters", () => { + expect(() => validateContractQuery(appInfoContract, { unexpected: "value" })).toThrow(); +}); + +test("system contract validates the app-info response shape", () => { + expect(() => validateContractResponse(appInfoContract, { + updateAvailable: false, + currentAppVersion: "v1.0.0", + userSignupDisabled: false, + })).not.toThrow(); + expect(() => validateContractResponse(appInfoContract, { + updateAvailable: "no", + currentAppVersion: "v1.0.0", + userSignupDisabled: false, + })).toThrow(); +}); + +test("system app-config contract permits arbitrary configuration fields", () => { + expect(() => validateContractResponse(appConfigContract, { + instanceName: "Dashwise", + nested: { enabled: true }, + })).not.toThrow(); +});