From 940464ffd560aad1222ed9d6642e43f51edbb44e Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Fri, 9 Oct 2026 23:47:40 +0200 Subject: [PATCH 01/18] Define shared activity contracts --- packages/types/activity.ts | 55 ++++++++++++++++++++++++++++++++++++++ packages/types/index.ts | 3 ++- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 packages/types/activity.ts diff --git a/packages/types/activity.ts b/packages/types/activity.ts new file mode 100644 index 00000000..e48a306e --- /dev/null +++ b/packages/types/activity.ts @@ -0,0 +1,55 @@ +import type { JsonValue } from "./sdk-types"; + +export type ActivitySeverity = "info" | "success" | "warning" | "error"; + +export type ActivityAction = { + label: string; + target: string; +}; + +/** Data accepted from an authenticated activity producer. Ownership and source are server-resolved. */ +export type PublishActivityInput = { + type: string; + title: string; + description?: string; + severity?: ActivitySeverity; + eventId?: string; + idempotencyKey?: string; + occurredAt?: string; + metadata?: Record; + action?: ActivityAction; +}; + +export type ActivityRecord = { + id: string; + ownerId: string; + source: string; + sourceId?: string; + eventId?: string; + type: string; + title: string; + description?: string; + severity: ActivitySeverity; + occurredAt: string; + createdAt: string; + metadata?: Record; + action?: ActivityAction; +}; + +export type ActivityQuery = { + page?: number; + perPage?: number; + source?: string; + type?: string; + severity?: ActivitySeverity; + from?: string; + to?: string; +}; + +export type ActivityPage = { + page: number; + perPage: number; + totalItems: number; + totalPages: number; + items: ActivityRecord[]; +}; diff --git a/packages/types/index.ts b/packages/types/index.ts index c45602d2..20d898be 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -1,2 +1,3 @@ export * from "./pocketbase/pocketbase-types"; -export * from "./links"; \ No newline at end of file +export * from "./links"; +export * from "./activity"; From 3775b5b7e0c2ca6e7d4a762a2a0aad62b509121b Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Fri, 9 Oct 2026 23:52:28 +0200 Subject: [PATCH 02/18] Add private activities persistence schema --- .../1785000009_created_activities.js | 205 ++++++++++++++++++ test/activity-collection-migration.test.ts | 50 +++++ 2 files changed, 255 insertions(+) create mode 100644 pocketbase/migrations/1785000009_created_activities.js create mode 100644 test/activity-collection-migration.test.ts diff --git a/pocketbase/migrations/1785000009_created_activities.js b/pocketbase/migrations/1785000009_created_activities.js new file mode 100644 index 00000000..5cbdf177 --- /dev/null +++ b/pocketbase/migrations/1785000009_created_activities.js @@ -0,0 +1,205 @@ +/// +migrate((app) => { + const collection = new Collection({ + createRule: null, + deleteRule: null, + fields: [ + { + autogeneratePattern: "[a-z0-9]{15}", + hidden: false, + id: "text3208210256", + max: 15, + min: 15, + name: "id", + pattern: "^[a-z0-9]+$", + presentable: false, + primaryKey: true, + required: true, + system: true, + type: "text", + }, + { + cascadeDelete: true, + collectionId: "_pb_users_auth_", + hidden: false, + id: "relation9812647350", + maxSelect: 1, + minSelect: 1, + name: "owner", + presentable: false, + required: true, + system: false, + type: "relation", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text5681247390", + max: 120, + min: 1, + name: "source", + pattern: "", + presentable: false, + primaryKey: false, + required: true, + system: false, + type: "text", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text1740382569", + max: 200, + min: 0, + name: "sourceId", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text2684019573", + max: 256, + min: 0, + name: "eventId", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text7836140259", + max: 256, + min: 0, + name: "idempotencyKey", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text4297613805", + max: 200, + min: 1, + name: "type", + pattern: "", + presentable: false, + primaryKey: false, + required: true, + system: false, + type: "text", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text6502391748", + max: 240, + min: 1, + name: "title", + pattern: "", + presentable: false, + primaryKey: false, + required: true, + system: false, + type: "text", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text9418302756", + max: 2000, + min: 0, + name: "description", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }, + { + hidden: false, + id: "select5381047296", + maxSelect: 1, + name: "severity", + presentable: false, + required: true, + system: false, + type: "select", + values: ["info", "success", "warning", "error"], + }, + { + autogeneratePattern: "", + hidden: false, + id: "text2097541836", + max: 40, + min: 20, + name: "occurredAt", + pattern: "", + presentable: false, + primaryKey: false, + required: true, + system: false, + type: "text", + }, + { + hidden: false, + id: "json8642031759", + maxSize: 16384, + name: "metadata", + presentable: false, + required: false, + system: false, + type: "json", + }, + { + hidden: false, + id: "json3175902468", + maxSize: 2048, + name: "action", + presentable: false, + required: false, + system: false, + type: "json", + }, + { + hidden: false, + id: "autodate1527493086", + name: "createdAt", + onCreate: true, + onUpdate: false, + presentable: false, + system: false, + type: "autodate", + }, + ], + id: "pbc_activity001", + indexes: [ + "CREATE INDEX idx_activities_owner_occurred_at ON activities (owner, occurredAt DESC)", + "CREATE UNIQUE INDEX idx_activities_owner_source_instance_idempotency ON activities (owner, source, sourceId, idempotencyKey) WHERE idempotencyKey != ''", + ], + listRule: null, + name: "activities", + system: false, + type: "base", + updateRule: null, + viewRule: null, + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_activity001"); + return app.delete(collection); +}); diff --git a/test/activity-collection-migration.test.ts b/test/activity-collection-migration.test.ts new file mode 100644 index 00000000..19e0d23d --- /dev/null +++ b/test/activity-collection-migration.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; + +const migration = readFileSync( + new URL("../pocketbase/migrations/1785000009_created_activities.js", import.meta.url), + "utf8", +); + +function fieldDefinition(name: string) { + const field = migration.match(new RegExp(`\\{[^{}]*name: "${name}"[^{}]*\\}`, "s")); + expect(field, `expected ${name} field in activity migration`).not.toBeNull(); + return field?.[0] ?? ""; +} + +describe("activities PocketBase migration", () => { + test("creates the private activities collection and removes it on rollback", () => { + expect(migration).toContain('name: "activities"'); + expect(migration).toContain('id: "pbc_activity001"'); + expect(migration).toContain('findCollectionByNameOrId("pbc_activity001")'); + expect(migration).toContain("return app.delete(collection)"); + + for (const rule of ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"]) { + expect(migration).toMatch(new RegExp(`${rule}: null`)); + } + }); + + test("stores contract fields with bounded input sizes", () => { + expect(fieldDefinition("owner")).toContain('collectionId: "_pb_users_auth_"'); + expect(fieldDefinition("owner")).toContain("required: true"); + expect(fieldDefinition("source")).toContain("max: 120"); + expect(fieldDefinition("sourceId")).toContain("max: 200"); + expect(fieldDefinition("eventId")).toContain("max: 256"); + expect(fieldDefinition("idempotencyKey")).toContain("max: 256"); + expect(fieldDefinition("type")).toContain("max: 200"); + expect(fieldDefinition("title")).toContain("max: 240"); + expect(fieldDefinition("description")).toContain("max: 2000"); + expect(fieldDefinition("occurredAt")).toContain('type: "text"'); + expect(fieldDefinition("createdAt")).toContain('type: "autodate"'); + expect(fieldDefinition("metadata")).toContain("maxSize: 16384"); + expect(fieldDefinition("action")).toContain("maxSize: 2048"); + expect(fieldDefinition("severity")).toContain('"error"'); + }); + + test("indexes owner chronology and idempotency per source instance", () => { + expect(migration).toContain("(owner, occurredAt DESC)"); + expect(migration).toContain("UNIQUE INDEX idx_activities_owner_source_instance_idempotency"); + expect(migration).toContain("(owner, source, sourceId, idempotencyKey)"); + expect(migration).toContain("WHERE idempotencyKey != ''"); + }); +}); From fa561bd9f06bc472abc664756865461cc5fd9b29 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Fri, 9 Oct 2026 23:54:34 +0200 Subject: [PATCH 03/18] Document activity layer architecture --- docs/ActivityLayer.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/ActivityLayer.md diff --git a/docs/ActivityLayer.md b/docs/ActivityLayer.md new file mode 100644 index 00000000..4917ca88 --- /dev/null +++ b/docs/ActivityLayer.md @@ -0,0 +1,25 @@ +# Activity layer + +## Architecture assessment + +Dashwise is a Bun monorepo. The Hono API and PocketBase data access live in `apps/backend`; the React dashboard lives in `apps/web`; shared contracts live in `packages/types`; PocketBase collections are managed by migrations in `pocketbase/migrations`. + +The backend resolves user sessions with `requireAuth` and uses its privileged PocketBase client for private collections. Notifications have their own records, APIs, read state, and forwarding. The authenticated `/api/v1/activity` WebSocket currently sends notification and calendar snapshots and routes shortcut results. The dashboard's existing `latest-activities` glanceable summarizes unread notifications and calendar events. Neither component is a persistent activity history. + +Integration definitions are stored YAML and resolved by the backend. They do not run an extension context today. A typed publisher client can provide the integration capability while the backend verifies the caller's session and integration ownership. + +No file-sharing or snippets prototypes were found on `experimental`. No existing activity collection or persistent event service was found. + +## Shared contract and boundaries + +`packages/types/activity.ts` defines the shared activity record, publish input, filters, and paginated response. Producers submit event details. The backend assigns the owner and source from the authenticated user and integration or trusted internal producer. + +Activities and notifications remain separate. Activities store events for history; notifications keep their current attention and read-state behavior. Realtime activity changes reuse the authenticated WebSocket, with a dedicated change signal that lets the frontend refresh activity queries. Existing notification, calendar, and shortcut socket messages remain in place. + +The PocketBase activity collection is private to the backend. User routes authenticate before querying and always filter by the authenticated owner. Integration publishing verifies that the integration belongs to that user. Metadata is bounded JSON, and navigation actions are limited to safe in-app targets. + +## Activity producers + +Internal producers call the shared backend service and handle publishing failures without interrupting their primary work. External integration producers use the typed integrationskit publisher and an integration-scoped API route. Producers should publish meaningful state changes and use event identifiers or idempotency keys where available. + +File sharing and snippets remain outside production scope. If those prototypes are introduced later, they can publish metadata about an event through the same service without copying file or snippet contents into activity records. From 5aad1c4b44dd952745202aa1301085fb86021abb Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Fri, 9 Oct 2026 23:59:50 +0200 Subject: [PATCH 04/18] Implement activity backend API --- apps/backend/openapi.yaml | 112 ++++++ apps/backend/src/index.ts | 11 +- apps/backend/src/jobs/index.ts | 14 + apps/backend/src/lib/config.ts | 5 + apps/backend/src/lib/data/activities.test.ts | 60 +++ apps/backend/src/lib/data/activities.ts | 364 +++++++++++++++++++ apps/backend/src/lib/data/widgets.ts | 8 + apps/backend/src/routes/activities.route.ts | 60 +++ apps/backend/src/routes/data.route.ts | 2 + 9 files changed, 634 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/lib/data/activities.test.ts create mode 100644 apps/backend/src/lib/data/activities.ts create mode 100644 apps/backend/src/routes/activities.route.ts diff --git a/apps/backend/openapi.yaml b/apps/backend/openapi.yaml index fb317510..7c76aed8 100644 --- a/apps/backend/openapi.yaml +++ b/apps/backend/openapi.yaml @@ -6,6 +6,7 @@ info: servers: - url: /api/v1 tags: + - name: activities - name: app - name: auth - name: config @@ -64,6 +65,71 @@ paths: - userSignupDisabled additionalProperties: false # END GENERATED SYSTEM CONTRACTS + /activities: + get: + tags: + - activities + summary: List the authenticated user's activities + parameters: + - name: page + in: query + schema: { type: integer, minimum: 1 } + - name: perPage + in: query + schema: { type: integer, minimum: 1, maximum: 100 } + - name: source + in: query + schema: { type: string, maxLength: 120 } + - name: type + in: query + schema: { type: string, maxLength: 120 } + - name: severity + in: query + schema: { type: string, enum: [info, success, warning, error] } + - name: from + in: query + schema: { type: string, format: date-time } + - name: to + in: query + schema: { type: string, format: date-time } + responses: + "200": + $ref: "#/components/responses/JsonOk" + "401": + $ref: "#/components/responses/JsonUnauthorized" + /activities/{id}: + get: + tags: + - activities + summary: Get an activity owned by the authenticated user + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + "200": + $ref: "#/components/responses/JsonOk" + "401": + $ref: "#/components/responses/JsonUnauthorized" + "404": + $ref: "#/components/responses/JsonNotFound" + delete: + tags: + - activities + summary: Delete an activity owned by the authenticated user + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + "200": + $ref: "#/components/responses/JsonOk" + "401": + $ref: "#/components/responses/JsonUnauthorized" + "404": + $ref: "#/components/responses/JsonNotFound" /auth/callback: get: tags: @@ -219,6 +285,52 @@ paths: responses: "200": $ref: "#/components/responses/JsonOk" + /integrations/{integrationId}/activities: + post: + tags: + - activities + summary: Publish an activity for an integration owned by the authenticated user + parameters: + - name: integrationId + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [type, title] + properties: + type: { type: string, maxLength: 120 } + title: { type: string, maxLength: 200 } + description: { type: string, maxLength: 2000 } + severity: { type: string, enum: [info, success, warning, error] } + eventId: { type: string, maxLength: 200 } + idempotencyKey: { type: string, maxLength: 256 } + occurredAt: { type: string, format: date-time } + metadata: { type: object, maxProperties: 128 } + action: + type: object + required: [label, target] + properties: + label: { type: string, maxLength: 80 } + target: { type: string, maxLength: 2048, description: Safe internal application path only. } + responses: + "201": + $ref: "#/components/responses/JsonOk" + "200": + description: Duplicate idempotent submission + content: + application/json: + schema: { type: object, properties: { activity: { type: object }, duplicate: { type: boolean } } } + "400": + $ref: "#/components/responses/JsonBadRequest" + "401": + $ref: "#/components/responses/JsonUnauthorized" + "404": + $ref: "#/components/responses/JsonNotFound" /integrations/test-endpoint: post: tags: diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 30f9ec4e..88d2f815 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -20,6 +20,7 @@ import { getMonitoringSshHostById, getMonitoringSshHostCredentials, getSystemAge import { getNotifications } from "./lib/data/notifications/items"; import { listIntegrations } from "./lib/data/integrations"; import { getUpcomingEvents } from "./lib/calendar"; +import { listActivities } from "./lib/data/activities"; import { systemAgentClient } from "./lib/systemAgent"; import { requireAuth } from "./routes/shared"; import authRoute from "./routes/auth.route"; @@ -154,11 +155,17 @@ app.get("/api/v1/activity", upgradeWebSocket((c) => { return calendarRefresh; }; const sendSnapshot = async () => { - const [notificationResult] = await Promise.all([ + const [notificationResult, activityResult] = await Promise.all([ getNotifications(userId), + listActivities(userId, { page: 1, perPage: 20 }), refreshCalendarEvents(), ]); - ws.send(JSON.stringify({ type: "activity:snapshot", notifications: notificationResult.items, calendarEvents })); + ws.send(JSON.stringify({ + type: "activity:snapshot", + notifications: notificationResult.items, + calendarEvents, + activities: activityResult.items, + })); }; unsubscribeActivity = subscribeActivity(userId, sendSnapshot); diff --git a/apps/backend/src/jobs/index.ts b/apps/backend/src/jobs/index.ts index 1bb8c1a1..03f8b9f9 100644 --- a/apps/backend/src/jobs/index.ts +++ b/apps/backend/src/jobs/index.ts @@ -19,6 +19,7 @@ import { processQueuedNotifications } from "./notifications/forwarder"; import { createLogger } from "../lib/logger"; import { migrateLegacyPageConfig } from "../lib/data/config"; import { getSuperuserPB } from "../lib/pb/pocketbase"; +import { deleteExpiredActivities } from "../lib/data/activities"; const execFileAsync = promisify(execFile); const logger = createLogger("Jobs"); @@ -111,6 +112,13 @@ const runNotificationForwarderJob = (source: string) => errorMessage: "Notification forwarder failed", }); +const runActivityRetentionJob = (source: string) => + runJob("activityRetention", () => deleteExpiredActivities(config.ACTIVITY_RETENTION_DAYS), { + startMessage: `Triggered by ${source}`, + successMessage: "Activity retention cleanup completed", + errorMessage: "Activity retention cleanup failed", + }); + const runLegacyUserConfigsMigration = async () => { const pb = await getSuperuserPB(); const users = await pb.collection("users").getFullList<{ id: string }>(500, { @@ -198,6 +206,11 @@ export function registerJobsCron() { Bun.cron(config.NOTIFICATION_FORWARDER_SCHEDULE, async () => { await runNotificationForwarderJob("cron schedule"); }); + + void runActivityRetentionJob("server start"); + Bun.cron("0 5 * * *", async () => { + await runActivityRetentionJob("cron schedule"); + }); } export const jobsApi = { @@ -210,5 +223,6 @@ export const jobsApi = { runDefaultIntegrationsJob, runNewsFeedBuilderJob, runNotificationForwarderJob, + runActivityRetentionJob, runLegacyUserConfigsMigrationJob, }; diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index 06e91fb1..862f5a35 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -20,6 +20,10 @@ const normalizedNewsSubscriptionRetention = Number(env.NEWS_SUBSCRIPTION_RETENTI const resolvedNewsSubscriptionRetention = Number.isInteger(normalizedNewsSubscriptionRetention) && normalizedNewsSubscriptionRetention > 0 ? normalizedNewsSubscriptionRetention : 50; +const normalizedActivityRetentionDays = Number(env.ACTIVITY_RETENTION_DAYS); +const resolvedActivityRetentionDays = Number.isInteger(normalizedActivityRetentionDays) && normalizedActivityRetentionDays > 0 + ? Math.min(normalizedActivityRetentionDays, 3_650) + : 90; const resolvedDeviceCodePending = Number.isInteger(Number(env.AUTH_DEVICE_CODE_MAX_PENDING)) && Number(env.AUTH_DEVICE_CODE_MAX_PENDING) > 0 ? Number(env.AUTH_DEVICE_CODE_MAX_PENDING) : 100; const resolvedDeviceCodeSockets = Number.isInteger(Number(env.AUTH_DEVICE_CODE_MAX_SOCKETS)) && Number(env.AUTH_DEVICE_CODE_MAX_SOCKETS) > 0 @@ -74,6 +78,7 @@ export const config = { UPDATE_CHECK_SCHEDULE: env.UPDATE_CHECK_SCHEDULE || "0 2 * * *", FEED_BUILDING_SCHEDULE: env.FEED_BUILDING_SCHEDULE || "*/30 * * * *", NEWS_SUBSCRIPTION_RETENTION: resolvedNewsSubscriptionRetention, + ACTIVITY_RETENTION_DAYS: resolvedActivityRetentionDays, AUTH_DEVICE_CODE_MAX_PENDING: resolvedDeviceCodePending, AUTH_DEVICE_CODE_MAX_SOCKETS: resolvedDeviceCodeSockets, AUTH_DEVICE_CODE_TTL_MS: resolvedDeviceCodeTtl, diff --git a/apps/backend/src/lib/data/activities.test.ts b/apps/backend/src/lib/data/activities.test.ts new file mode 100644 index 00000000..cd1d3dc7 --- /dev/null +++ b/apps/backend/src/lib/data/activities.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; + +import { + ActivityInputError, + validateActivityQuery, + validatePublishActivityInput, +} from "./activities"; + +describe("activity backend input validation", () => { + test("accepts typed events with bounded metadata and internal actions", () => { + expect(validatePublishActivityInput({ + type: "service.offline", + title: "Nextcloud is offline", + severity: "warning", + occurredAt: "2026-10-09T10:30:00.000Z", + metadata: { serviceId: "nextcloud", retry: 2 }, + action: { label: "Open service", target: "/services/nextcloud" }, + })).toMatchObject({ severity: "warning", title: "Nextcloud is offline" }); + }); + + test("rejects producer attempts to choose owner or source", () => { + expect(() => validatePublishActivityInput({ + type: "test.event", + title: "Test", + ownerId: "another-user", + source: "spoofed-source", + })).toThrow(ActivityInputError); + }); + + test("rejects external, protocol-relative, and executable action targets", () => { + for (const target of ["https://example.com", "//example.com", "javascript:alert(1)", "/\\evil.example"]) { + expect(() => validatePublishActivityInput({ + type: "test.event", + title: "Test", + action: { label: "Open", target }, + })).toThrow(ActivityInputError); + } + }); + + test("rejects oversized metadata and credential-like metadata keys", () => { + expect(() => validatePublishActivityInput({ + type: "test.event", + title: "Test", + metadata: { data: "x".repeat(17 * 1024) }, + })).toThrow(/16 KB/); + expect(() => validatePublishActivityInput({ + type: "test.event", + title: "Test", + metadata: { nested: { accessToken: "secret" } }, + })).toThrow(/credentials or secrets/); + }); + + test("validates query pagination and chronological range", () => { + expect(validateActivityQuery({ page: "2", perPage: "50", severity: "error" })) + .toMatchObject({ page: 2, perPage: 50, severity: "error" }); + expect(() => validateActivityQuery({ perPage: "1000" })).toThrow(/pagination/); + expect(() => validateActivityQuery({ from: "2026-10-10T00:00:00Z", to: "2026-10-09T00:00:00Z" })) + .toThrow(/from filter/); + }); +}); diff --git a/apps/backend/src/lib/data/activities.ts b/apps/backend/src/lib/data/activities.ts new file mode 100644 index 00000000..b231435f --- /dev/null +++ b/apps/backend/src/lib/data/activities.ts @@ -0,0 +1,364 @@ +import { z } from "zod"; + +import type { + ActivityPage, + ActivityQuery, + ActivityRecord, + PublishActivityInput, +} from "@dashwise/types"; +import type { JsonValue } from "@dashwise/types/sdk"; +import { ApiActionError } from "./auth"; +import { getSuperuserPB } from "../pb/pocketbase"; +import { broadcastActivity } from "../activity"; + +const SEVERITIES = ["info", "success", "warning", "error"] as const; +const MAX_REQUEST_BYTES = 100 * 1024; +const MAX_METADATA_BYTES = 16 * 1024; +const MAX_METADATA_DEPTH = 8; +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; +const PUBLISH_RATE_WINDOW_MS = 60_000; +const PUBLISH_RATE_LIMIT = 120; +const publishRateWindows = new Map(); +const jsonValueSchema: z.ZodType = z.lazy(() => z.union([ + z.string(), + z.number().finite(), + z.boolean(), + z.null(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), +])); + +const activityInputSchema = z.object({ + type: z.string().trim().min(1).max(120), + title: z.string().trim().min(1).max(200), + description: z.string().trim().max(2_000).optional(), + severity: z.enum(SEVERITIES).optional().default("info"), + eventId: z.string().trim().min(1).max(200).optional(), + idempotencyKey: z.string().trim().min(1).max(256).optional(), + occurredAt: z.string().datetime({ offset: true }).optional(), + metadata: z.record(z.string(), jsonValueSchema).optional(), + action: z.object({ + label: z.string().trim().min(1).max(80), + target: z.string().trim().min(1).max(2_048), + }).strict().optional(), +}).strict(); + +type ActivityRow = Record & { + id: string; + owner: string; + source: string; + sourceId?: string; + eventId?: string; + type: string; + title: string; + description?: string; + severity: ActivityRecord["severity"]; + occurredAt: string; + createdAt?: string; + created?: string; +}; + +export class ActivityInputError extends ApiActionError { + constructor(message: string) { + super(message, 400, { error: message }); + this.name = "ActivityInputError"; + } +} + +export function validatePublishActivityInput(value: unknown): PublishActivityInput { + const parsed = activityInputSchema.safeParse(value); + if (!parsed.success) { + throw new ActivityInputError("Invalid activity payload"); + } + + let serialized: string; + try { + serialized = JSON.stringify(parsed.data); + } catch { + throw new ActivityInputError("Invalid activity payload"); + } + if (Buffer.byteLength(serialized, "utf8") > MAX_REQUEST_BYTES) { + throw new ActivityInputError("Activity payload exceeds the 100 KB limit"); + } + + if (parsed.data.metadata) { + const metadataJSON = JSON.stringify(parsed.data.metadata); + if (Buffer.byteLength(metadataJSON, "utf8") > MAX_METADATA_BYTES) { + throw new ActivityInputError("Activity metadata exceeds the 16 KB limit"); + } + if (hasExcessiveDepth(parsed.data.metadata, 0)) { + throw new ActivityInputError("Activity metadata nesting exceeds the limit"); + } + if (hasSensitiveMetadataKey(parsed.data.metadata)) { + throw new ActivityInputError("Activity metadata must not contain credentials or secrets"); + } + } + + if (parsed.data.action && !isSafeInternalTarget(parsed.data.action.target)) { + throw new ActivityInputError("Activity actions must target a safe internal path"); + } + + return { + ...parsed.data, + ...(parsed.data.description ? { description: parsed.data.description } : {}), + }; +} + +export function validateActivityQuery(query: Record): ActivityQuery { + const page = parseIntegerQuery(query.page, 1, 1_000_000); + const perPage = parseIntegerQuery(query.perPage, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE); + const severity = typeof query.severity === "string" ? query.severity : undefined; + if (severity && !SEVERITIES.includes(severity as typeof SEVERITIES[number])) { + throw new ActivityInputError("Invalid severity filter"); + } + + const from = parseOptionalDate(query.from, "from"); + const to = parseOptionalDate(query.to, "to"); + if (from && to && Date.parse(from) > Date.parse(to)) { + throw new ActivityInputError("The from filter must be earlier than or equal to to"); + } + + return { + page, + perPage, + ...(optionalFilter(query.source, "source") ? { source: optionalFilter(query.source, "source") } : {}), + ...(optionalFilter(query.type, "type") ? { type: optionalFilter(query.type, "type") } : {}), + ...(severity ? { severity: severity as ActivityQuery["severity"] } : {}), + ...(from ? { from } : {}), + ...(to ? { to } : {}), + }; +} + +export async function publishIntegrationActivity( + userId: string, + integrationId: string, + untrustedInput: unknown, +) { + const input = validatePublishActivityInput(untrustedInput); + enforcePublishRateLimit(userId); + const pb = await getSuperuserPB(); + const integration = await pb.collection("integrations").getOne(integrationId).catch(() => null); + if (!integration || relationId(integration.user) !== userId) { + throw new ApiActionError("Integration not found", 404, { error: "Integration not found" }); + } + + const sourceId = integration.id; + const rawSource = typeof integration.source === "string" ? integration.source.trim() : ""; + const source = rawSource.slice(0, 120) || "integration"; + const rawKey = input.idempotencyKey ?? input.eventId; + const idempotencyKey = rawKey; + + if (idempotencyKey) { + const existing = await findDuplicate(pb, userId, source, sourceId, idempotencyKey); + if (existing) return { activity: mapActivity(existing), duplicate: true }; + } + + const payload = { + owner: userId, + source, + sourceId, + ...(input.eventId ? { eventId: input.eventId } : {}), + ...(idempotencyKey ? { idempotencyKey } : {}), + type: input.type, + title: input.title, + ...(input.description ? { description: input.description } : {}), + severity: input.severity ?? "info", + occurredAt: input.occurredAt ? new Date(input.occurredAt).toISOString() : new Date().toISOString(), + ...(input.metadata ? { metadata: input.metadata } : {}), + ...(input.action ? { action: input.action } : {}), + }; + + try { + const created = await pb.collection("activities").create(payload) as ActivityRow; + broadcastActivity(userId); + return { activity: mapActivity(created), duplicate: false }; + } catch (error) { + if (idempotencyKey) { + const existing = await findDuplicate(pb, userId, source, sourceId, idempotencyKey); + if (existing) return { activity: mapActivity(existing), duplicate: true }; + } + throw error; + } +} + +export async function listActivities(userId: string, query: ActivityQuery = {}): Promise { + const pb = await getSuperuserPB(); + const page = query.page ?? 1; + const perPage = Math.min(query.perPage ?? DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE); + const clauses = [`owner = ${filterString(userId)}`]; + if (query.source) clauses.push(`source = ${filterString(query.source)}`); + if (query.type) clauses.push(`type = ${filterString(query.type)}`); + if (query.severity) clauses.push(`severity = ${filterString(query.severity)}`); + if (query.from) clauses.push(`occurredAt >= ${filterString(query.from)}`); + if (query.to) clauses.push(`occurredAt <= ${filterString(query.to)}`); + + const result = await pb.collection("activities").getList(page, perPage, { + filter: clauses.join(" && "), + sort: "-occurredAt,-createdAt", + }); + + return { + page: result.page, + perPage: result.perPage, + totalItems: result.totalItems, + totalPages: result.totalPages, + items: result.items.map(mapActivity), + }; +} + +export async function getActivity(userId: string, activityId: string) { + const pb = await getSuperuserPB(); + const record = await pb.collection("activities").getOne(activityId).catch(() => null); + if (!record || record.owner !== userId) return null; + return mapActivity(record); +} + +export async function deleteActivity(userId: string, activityId: string) { + const pb = await getSuperuserPB(); + const record = await pb.collection("activities").getOne(activityId).catch(() => null); + if (!record || record.owner !== userId) return false; + await pb.collection("activities").delete(activityId); + broadcastActivity(userId); + return true; +} + +export async function deleteExpiredActivities(retentionDays: number) { + const days = Number.isInteger(retentionDays) ? Math.max(1, Math.min(retentionDays, 3_650)) : 90; + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + const pb = await getSuperuserPB(); + const affectedOwners = new Set(); + let deleted = 0; + while (true) { + const expired = await pb.collection("activities").getList(1, 500, { + filter: `occurredAt < ${filterString(cutoff)}`, + fields: "id,owner", + sort: "occurredAt", + }); + if (expired.items.length === 0) break; + for (const record of expired.items) affectedOwners.add(record.owner); + const results = await Promise.allSettled(expired.items.map((record) => pb.collection("activities").delete(record.id))); + deleted += results.filter((result) => result.status === "fulfilled").length; + if (results.every((result) => result.status === "rejected")) break; + } + for (const ownerId of affectedOwners) broadcastActivity(ownerId); + return { deleted }; +} + +function mapActivity(row: ActivityRow): ActivityRecord { + const metadata = asObject(row.metadata); + const action = asObject(row.action); + return { + id: row.id, + ownerId: row.owner, + source: row.source, + ...(typeof row.sourceId === "string" && row.sourceId ? { sourceId: row.sourceId } : {}), + ...(typeof row.eventId === "string" && row.eventId ? { eventId: row.eventId } : {}), + type: row.type, + title: row.title, + ...(typeof row.description === "string" && row.description ? { description: row.description } : {}), + severity: SEVERITIES.includes(row.severity) ? row.severity : "info", + occurredAt: row.occurredAt, + createdAt: typeof row.createdAt === "string" ? row.createdAt : (row.created ?? row.occurredAt), + ...(metadata ? { metadata: metadata as ActivityRecord["metadata"] } : {}), + ...(action && typeof action.label === "string" && typeof action.target === "string" + ? { action: { label: action.label, target: action.target } } + : {}), + }; +} + +async function findDuplicate( + pb: Awaited>, + userId: string, + source: string, + sourceId: string, + idempotencyKey: string, +) { + return await pb.collection("activities").getFirstListItem( + `owner = ${filterString(userId)} && source = ${filterString(source)} && sourceId = ${filterString(sourceId)} && idempotencyKey = ${filterString(idempotencyKey)}`, + ).catch(() => null); +} + +function filterString(value: string) { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`; +} + +function relationId(value: unknown) { + if (typeof value === "string") return value; + if (value && typeof value === "object" && "id" in value && typeof value.id === "string") return value.id; + return ""; +} + +function asObject(value: unknown): Record | null { + if (typeof value === "string") { + try { value = JSON.parse(value); } catch { return null; } + } + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : null; +} + +function hasExcessiveDepth(value: unknown, depth: number): boolean { + if (depth > MAX_METADATA_DEPTH) return true; + if (Array.isArray(value)) return value.some((item) => hasExcessiveDepth(item, depth + 1)); + if (typeof value !== "object" || value === null) return false; + return Object.values(value).some((item) => hasExcessiveDepth(item, depth + 1)); +} + +function hasSensitiveMetadataKey(value: unknown): boolean { + if (Array.isArray(value)) return value.some(hasSensitiveMetadataKey); + if (typeof value !== "object" || value === null) return false; + return Object.entries(value).some(([key, item]) => + /(^|[_-])(password|passwd|secret|token|access[_-]?token|refresh[_-]?token|api[_-]?key|auth|authorization|credential|private[_-]?key)([_-]|$)/i.test(key.replace(/([a-z0-9])([A-Z])/g, "$1_$2")) || hasSensitiveMetadataKey(item) + ); +} + +function enforcePublishRateLimit(userId: string) { + const now = Date.now(); + for (const [key, window] of publishRateWindows) { + if (now - window.startedAt >= PUBLISH_RATE_WINDOW_MS) publishRateWindows.delete(key); + } + const window = publishRateWindows.get(userId); + if (!window || now - window.startedAt >= PUBLISH_RATE_WINDOW_MS) { + if (publishRateWindows.size >= 10_000) publishRateWindows.clear(); + publishRateWindows.set(userId, { startedAt: now, count: 1 }); + return; + } + if (window.count >= PUBLISH_RATE_LIMIT) { + throw new ApiActionError("Activity publish rate limit exceeded", 429, { error: "Activity publish rate limit exceeded" }); + } + window.count += 1; +} + +function isSafeInternalTarget(target: string) { + if (!target.startsWith("/") || target.startsWith("//") || target.includes("\\") || /[\u0000-\u001f\u007f]/.test(target)) return false; + try { + const parsed = new URL(target, "https://dashwise.invalid"); + return parsed.origin === "https://dashwise.invalid" && parsed.pathname.startsWith("/"); + } catch { + return false; + } +} + +function parseIntegerQuery(value: unknown, defaultValue: number, max: number) { + if (value === undefined || value === "") return defaultValue; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) throw new ActivityInputError("Invalid pagination parameters"); + return parsed; +} + +function optionalFilter(value: unknown, name: string) { + if (value === undefined || value === "") return undefined; + if (typeof value !== "string" || value.trim().length === 0 || value.length > 120) { + throw new ActivityInputError(`Invalid ${name} filter`); + } + return value.trim(); +} + +function parseOptionalDate(value: unknown, name: string) { + if (value === undefined || value === "") return undefined; + if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) { + throw new ActivityInputError(`Invalid ${name} date filter`); + } + return new Date(value).toISOString(); +} diff --git a/apps/backend/src/lib/data/widgets.ts b/apps/backend/src/lib/data/widgets.ts index 1e6ca4c4..48992934 100644 --- a/apps/backend/src/lib/data/widgets.ts +++ b/apps/backend/src/lib/data/widgets.ts @@ -278,6 +278,14 @@ async function getDefaultGlanceables(): Promise { builtinGlanceables, weatherGlanceables, [{ + type: "activity-stream", + displayName: "Activity Stream", + description: "Recent activity from your integrations and Dashwise.", + exampleProps: { limit: 10 }, + integrationName: "Builtin", + integrationDisplayName: "Builtin", + appName: "Builtin", + }, { type: "latest-activities", displayName: "Latest Activities", exampleProps: {}, diff --git a/apps/backend/src/routes/activities.route.ts b/apps/backend/src/routes/activities.route.ts new file mode 100644 index 00000000..77be98a0 --- /dev/null +++ b/apps/backend/src/routes/activities.route.ts @@ -0,0 +1,60 @@ +import { Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; + +import { + ActivityInputError, + deleteActivity, + getActivity, + listActivities, + publishIntegrationActivity, + validateActivityQuery, +} from "../lib/data/activities"; +import { ApiActionError } from "../lib/data/auth"; +import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared"; + +const activitiesRoute = new Hono(); + +activitiesRoute.get("/api/v1/activities", withJson(async (c) => { + const { userId } = await requireAuth({ token: readAuthToken(c) }); + const query = validateActivityQuery(c.req.query()); + return listActivities(userId, query); +})); + +activitiesRoute.get("/api/v1/activities/:id", withJson(async (c) => { + const { userId } = await requireAuth({ token: readAuthToken(c) }); + const activity = await getActivity(userId, c.req.param("id")); + if (!activity) throw new ApiActionError("Activity not found", 404, { error: "Activity not found" }); + return { activity }; +})); + +activitiesRoute.delete("/api/v1/activities/:id", withJson(async (c) => { + const { userId } = await requireAuth({ token: readAuthToken(c) }); + const deleted = await deleteActivity(userId, c.req.param("id")); + if (!deleted) throw new ApiActionError("Activity not found", 404, { error: "Activity not found" }); + return { ok: true }; +})); + +activitiesRoute.post( + "/api/v1/integrations/:integrationId/activities", + bodyLimit({ maxSize: 100 * 1024, onError: (c) => c.json({ error: "Activity payload exceeds the 100 KB limit" }, 413) }), + async (c) => { + try { + const { userId } = await requireAuth({ token: readAuthToken(c) }); + const result = await publishIntegrationActivity( + userId, + c.req.param("integrationId"), + await readJsonBody(c), + ); + return c.json(result, result.duplicate ? 200 : 201); + } catch (error) { + if (error instanceof ActivityInputError) return c.json({ error: error.message }, 400); + if (error instanceof ApiActionError) return c.json(error.body ?? { error: error.message }, error.status as 401 | 403 | 404 | 429); + const status = typeof error === "object" && error && "status" in error && typeof error.status === "number" + ? error.status + : 500; + return c.json({ error: "Internal Server Error" }, status as 400 | 401 | 403 | 404 | 413 | 429 | 500); + } + }, +); + +export default activitiesRoute; diff --git a/apps/backend/src/routes/data.route.ts b/apps/backend/src/routes/data.route.ts index bc40b420..42392d5d 100644 --- a/apps/backend/src/routes/data.route.ts +++ b/apps/backend/src/routes/data.route.ts @@ -9,6 +9,7 @@ import pageConfigRoute from "./pageConfig.route"; import wallpapersRoute from "./wallpapers.route"; import widgetsRoute from "./widgets.route"; import shortcutsRoute from "./shortcuts.route"; +import activitiesRoute from "./activities.route"; const dataRoute = new Hono(); @@ -21,5 +22,6 @@ dataRoute.route("/", notificationsRoute); dataRoute.route("/", monitoringRoute); dataRoute.route("/", wallpapersRoute); dataRoute.route("/", shortcutsRoute); +dataRoute.route("/", activitiesRoute); export default dataRoute; From 446df3f8d436223ca953af21defe68b3c90afac2 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 10 Oct 2026 00:01:15 +0200 Subject: [PATCH 05/18] Add activity history and glanceable UI --- .../(authenticated)/apps/activity/page.tsx | 97 +++++++++++++++++++ .../(authenticated)/settings/pages/page.tsx | 20 +++- .../components/activity/ActivityStream.tsx | 60 ++++++++++++ .../settings/pages/EditGlanceablesView.tsx | 44 ++++++++- .../widgets/dashboard/GlanceableClock.tsx | 6 ++ apps/web/src/context/ActivityContext.tsx | 4 + apps/web/src/hooks/useActivities.ts | 14 +++ apps/web/src/lib/activityUtils.test.ts | 19 ++++ apps/web/src/lib/activityUtils.ts | 19 ++++ apps/web/src/lib/apiClient.ts | 26 +++++ apps/web/src/lib/queryClient.ts | 5 + apps/web/src/router.tsx | 5 +- 12 files changed, 313 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/app/(authenticated)/apps/activity/page.tsx create mode 100644 apps/web/src/components/activity/ActivityStream.tsx create mode 100644 apps/web/src/hooks/useActivities.ts create mode 100644 apps/web/src/lib/activityUtils.test.ts create mode 100644 apps/web/src/lib/activityUtils.ts diff --git a/apps/web/src/app/(authenticated)/apps/activity/page.tsx b/apps/web/src/app/(authenticated)/apps/activity/page.tsx new file mode 100644 index 00000000..f90d3daf --- /dev/null +++ b/apps/web/src/app/(authenticated)/apps/activity/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { Link, useParams } from "react-router-dom"; +import { useState } from "react"; +import type { ActivityQuery, ActivitySeverity } from "@dashwise/types"; +import AppTemplate, { Content, Sidebar, Tab } from "@/components/apps/LayoutTemplate"; +import { ActivityRow } from "@/components/activity/ActivityStream"; +import { safeActivityTarget } from "@/lib/activityUtils"; +import { useActivityDetail, useActivityPage } from "@/hooks/useActivities"; + +const inputClass = "h-9 min-w-0 rounded-md border border-white/15 bg-black/20 px-3 text-sm text-white outline-none focus-visible:ring-2 focus-visible:ring-primary"; + +export default function ActivityHistoryPage() { + const { activityId } = useParams(); + return ( + + + + {activityId ? : } + + + ); +} + +function ActivityHistory() { + const [filters, setFilters] = useState({ page: 1, perPage: 20 }); + const query = useActivityPage(filters); + const updateFilter = (patch: Partial) => setFilters((current) => ({ ...current, ...patch, page: 1 })); + + return ( +
+
+

Activity history

+

Events from integrations and Dashwise appear here.

+
+
+ + + + + +
+ {query.isLoading ?

Loading activity…

: null} + {query.isError ?

Activity history could not be loaded.

: null} + {query.isSuccess && query.data.items.length === 0 ?

No matching activity.

: null} + {query.data?.items.length ?
+ {query.data.items.map((activity) => )} +
: null} + {query.data && query.data.totalPages > 1 && ( +
+ Page {query.data.page} of {query.data.totalPages} +
+ + +
+
+ )} +
+ ); +} + +function ActivityDetail({ activityId }: { activityId: string }) { + const query = useActivityDetail(activityId); + const actionTarget = safeActivityTarget(query.data?.action?.target); + return ( +
+ ← Activity history + {query.isLoading &&

Loading activity…

} + {query.isError &&

This activity could not be loaded.

} + {query.data &&
+
+

{query.data.title}

+ +
+

{query.data.source} · {query.data.type} · {query.data.severity}

+ {query.data.description &&

{query.data.description}

} + {actionTarget && query.data.action && {query.data.action.label}} + {query.data.metadata && Object.keys(query.data.metadata).length > 0 &&
+

Details

+
{JSON.stringify(query.data.metadata, null, 2)}
+
} +
} +
+ ); +} diff --git a/apps/web/src/app/(authenticated)/settings/pages/page.tsx b/apps/web/src/app/(authenticated)/settings/pages/page.tsx index 1d3bd3c7..9a00d40b 100644 --- a/apps/web/src/app/(authenticated)/settings/pages/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/pages/page.tsx @@ -160,9 +160,25 @@ export default function SettingsPagesPage() { : {}, })) : []; - setGlanceablesCatalog(catalog); + const localActivityStream = { + type: "activity-stream", + name: "Activity stream", + appName: "Builtin", + exampleProps: { maxItems: 5, source: "", type: "" }, + properties: { maxItems: { type: "number" }, source: { type: "string" }, type: { type: "string" } }, + }; + setGlanceablesCatalog([ + ...catalog.filter((entry) => entry.type !== localActivityStream.type), + localActivityStream, + ]); }) - .catch(() => setGlanceablesCatalog([])); + .catch(() => setGlanceablesCatalog([{ + type: "activity-stream", + name: "Activity stream", + appName: "Builtin", + exampleProps: { maxItems: 5, source: "", type: "" }, + properties: { maxItems: { type: "number" }, source: { type: "string" }, type: { type: "string" } }, + }])); }, [withAuth]); useEffect(() => { diff --git a/apps/web/src/components/activity/ActivityStream.tsx b/apps/web/src/components/activity/ActivityStream.tsx new file mode 100644 index 00000000..aea0b000 --- /dev/null +++ b/apps/web/src/components/activity/ActivityStream.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Link } from "react-router-dom"; +import type { ActivityRecord } from "@dashwise/types"; +import { useActivityPage } from "@/hooks/useActivities"; +import { formatActivityTime, safeActivityTarget } from "@/lib/activityUtils"; + +const severityColor: Record = { + info: "bg-sky-300", + success: "bg-emerald-300", + warning: "bg-amber-300", + error: "bg-rose-400", +}; + +export function ActivityRow({ activity, compact = false }: { activity: ActivityRecord; compact?: boolean }) { + const actionTarget = safeActivityTarget(activity.action?.target); + return ( +
+ +
+ +
+ {activity.title} + +
+ {!compact && activity.description &&

{activity.description}

} +

{activity.source} · {activity.type}

+ + {actionTarget && activity.action && ( + event.stopPropagation()}> + {activity.action.label} + + )} +
+
+ ); +} + +export default function ActivityStream({ maxItems = 5, source, type, compact = false }: { + maxItems?: number; + source?: string; + type?: string; + compact?: boolean; +}) { + const perPage = Math.max(1, Math.min(25, Number(maxItems) || 5)); + const query = useActivityPage({ page: 1, perPage, source: source?.trim() || undefined, type: type?.trim() || undefined }); + + if (query.isLoading) return

Loading activity…

; + if (query.isError) return

Activity could not be loaded.

; + if (!query.data?.items.length) return

No activity yet.

; + + return ( +
+ {query.data.items.slice(0, perPage).map((activity) => )} +
+ View activity history +
+
+ ); +} diff --git a/apps/web/src/components/settings/pages/EditGlanceablesView.tsx b/apps/web/src/components/settings/pages/EditGlanceablesView.tsx index b6e5a0a1..9fe6a3ce 100644 --- a/apps/web/src/components/settings/pages/EditGlanceablesView.tsx +++ b/apps/web/src/components/settings/pages/EditGlanceablesView.tsx @@ -19,6 +19,7 @@ import { useLocalization } from "@/context/LocalizationContext"; import { useActivity } from "@/context/ActivityContext"; import useAuth from "@/context/useAuth"; import { getIntegrationWithGlanceableAction } from '@/lib/apiClient'; +import ActivityStream from "@/components/activity/ActivityStream"; const PROGRESS_PERIOD_OPTIONS = [ { value: "year", label: "Year" }, @@ -290,7 +291,7 @@ export function EditGlanceablesView({ }`} >
- : + />}

{glanceable.name}

{getGlanceableGroupName(glanceable)}

@@ -421,6 +422,43 @@ export function EditGlanceablesView({ )} + {selectedClockType === "activity-stream" && ( +
+
+ + updateSelectedParams({ maxItems: Math.max(1, Math.min(25, Number(event.target.value) || 1)) })} + className="h-9 rounded-full border-white/20 bg-transparent px-3 text-sm" + /> +
+
+ + updateSelectedParams({ source: event.target.value })} + placeholder="All sources" + className="h-9 rounded-full border-white/20 bg-transparent px-3 text-sm" + /> +
+
+ + updateSelectedParams({ type: event.target.value })} + placeholder="All types" + className="h-9 rounded-full border-white/20 bg-transparent px-3 text-sm" + /> +
+
+ )} + {integrationInfo?.environmentDefinitions && Object.entries(integrationInfo.environmentDefinitions).map(([key, def]) => (
@@ -439,7 +477,7 @@ export function EditGlanceablesView({
))} - {!['date', 'countdown', 'weather'].includes(selectedClockType) && !integrationInfo?.environmentDefinitions && ( + {!['date', 'countdown', 'weather', 'activity-stream'].includes(selectedClockType) && !integrationInfo?.environmentDefinitions && (

No configurable properties for this glanceable.

diff --git a/apps/web/src/components/widgets/dashboard/GlanceableClock.tsx b/apps/web/src/components/widgets/dashboard/GlanceableClock.tsx index 05787c68..fdc382bf 100644 --- a/apps/web/src/components/widgets/dashboard/GlanceableClock.tsx +++ b/apps/web/src/components/widgets/dashboard/GlanceableClock.tsx @@ -14,6 +14,7 @@ import { useActivity } from "@/context/ActivityContext"; import { Bell, CalendarDays } from "lucide-react"; import { readPageIntegrationConsumer } from "@/lib/pageIntegrationDataCache"; import type { ClockAppearance } from "../../settings/ClockFontSelectionCarousel"; +import ActivityStream from "@/components/activity/ActivityStream"; type ResolvedGlanceablePayload = { consumer: "glanceable"; @@ -41,6 +42,7 @@ const LOCAL_ONLY_GLANCEABLES = new Set([ "month-progress", "year-progress", "latest-activities", + "activity-stream", ]); type GlanceableClockWidgetProps = WidgetItemProps & { @@ -343,6 +345,10 @@ function ResolvedGlanceable({ return ; } + if (type === "activity-stream") { + return
; + } + if (preloaded?.success === false) { return (
diff --git a/apps/web/src/context/ActivityContext.tsx b/apps/web/src/context/ActivityContext.tsx index d2014dea..7ca7647a 100644 --- a/apps/web/src/context/ActivityContext.tsx +++ b/apps/web/src/context/ActivityContext.tsx @@ -81,6 +81,10 @@ export function ActivityProvider({ children }: { children: ReactNode }) { }); return; } + if (message.type === "activities:changed") { + void queryClient.invalidateQueries({ queryKey: ["api", token, ...queryKeys.activities.root] }); + return; + } if (message.type !== "activity:snapshot") return; const nextNotifications = Array.isArray(message.notifications) ? message.notifications : []; setNotifications(nextNotifications); diff --git a/apps/web/src/hooks/useActivities.ts b/apps/web/src/hooks/useActivities.ts new file mode 100644 index 00000000..fba93cf2 --- /dev/null +++ b/apps/web/src/hooks/useActivities.ts @@ -0,0 +1,14 @@ +import type { ActivityQuery } from "@dashwise/types"; +import { useApiQuery } from "@/hooks/useApiQuery"; +import { getActivityAction, getActivityPageAction } from "@/lib/apiClient"; +import { queryKeys } from "@/lib/queryClient"; + +export function useActivityPage(query: ActivityQuery = {}) { + return useApiQuery(queryKeys.activities.list(query), (auth) => getActivityPageAction(auth, query)); +} + +export function useActivityDetail(activityId: string) { + return useApiQuery(queryKeys.activities.detail(activityId), (auth) => getActivityAction(auth, activityId), { + enabled: Boolean(activityId), + }); +} diff --git a/apps/web/src/lib/activityUtils.test.ts b/apps/web/src/lib/activityUtils.test.ts new file mode 100644 index 00000000..f2d00ceb --- /dev/null +++ b/apps/web/src/lib/activityUtils.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { formatActivityTime, safeActivityTarget } from "./activityUtils"; + +describe("activity utilities", () => { + test("allows safe same-origin paths and preserves query and hash", () => { + expect(safeActivityTarget("/apps/monitoring?host=one#status")).toBe("/apps/monitoring?host=one#status"); + }); + + test("rejects external, protocol-relative, and malformed paths", () => { + expect(safeActivityTarget("https://example.com")).toBeNull(); + expect(safeActivityTarget("//example.com/path")).toBeNull(); + expect(safeActivityTarget("/\\\\example.com")).toBeNull(); + }); + + test("formats activity times and handles invalid dates", () => { + expect(formatActivityTime("bad date", 0)).toBe(""); + expect(formatActivityTime("1970-01-01T00:00:00.000Z", 0)).toBe("now"); + }); +}); diff --git a/apps/web/src/lib/activityUtils.ts b/apps/web/src/lib/activityUtils.ts new file mode 100644 index 00000000..f4dde471 --- /dev/null +++ b/apps/web/src/lib/activityUtils.ts @@ -0,0 +1,19 @@ +export function safeActivityTarget(target?: string) { + if (!target || !target.startsWith("/") || target.startsWith("//") || target.includes("\\") || /[\u0000-\u001f\u007f]/.test(target)) return null; + try { + const parsed = new URL(target, "https://dashwise.invalid"); + return parsed.origin === "https://dashwise.invalid" ? `${parsed.pathname}${parsed.search}${parsed.hash}` : null; + } catch { + return null; + } +} + +export function formatActivityTime(value: string, now = Date.now()) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const seconds = Math.round((date.getTime() - now) / 1000); + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [["year", 31_536_000], ["month", 2_592_000], ["week", 604_800], ["day", 86_400], ["hour", 3_600], ["minute", 60]]; + const [unit, size] = units.find(([, unitSize]) => Math.abs(seconds) >= unitSize) ?? ["second", 1]; + return formatter.format(Math.round(seconds / size), unit); +} diff --git a/apps/web/src/lib/apiClient.ts b/apps/web/src/lib/apiClient.ts index 393fa280..4d216469 100644 --- a/apps/web/src/lib/apiClient.ts +++ b/apps/web/src/lib/apiClient.ts @@ -15,6 +15,7 @@ import type { NewsUpdateInput, } from "@dashwise/types/sdk"; import type { PageConfig } from "@dashwise/types/sdk"; +import type { ActivityPage, ActivityQuery, ActivityRecord } from "@dashwise/types"; import config from "@/lib/config"; import { getClientSessionHeaders } from "@/lib/session"; import { client } from "./api/client.gen"; @@ -75,6 +76,31 @@ export function authHeaders(auth?: ActionAuth | null): Record | }; } +async function activityRequest(auth: ActionAuth, path: string, query?: Record): Promise { + const url = new URL(backendUrl(path)); + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined && value !== "") url.searchParams.set(key, String(value)); + } + const response = await fetch(url, { headers: authHeaders(auth) }); + handleUnauthorizedResponse(response); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + const error = new Error(typeof body?.error === "string" ? body.error : "Activity request failed") as Error & { status?: number }; + error.status = response.status; + throw error; + } + return body as T; +} + +export async function getActivityPageAction(auth: ActionAuth, query: ActivityQuery = {}): Promise { + return activityRequest(auth, "/api/v1/activities", query as Record); +} + +export async function getActivityAction(auth: ActionAuth, activityId: string): Promise { + const result = await activityRequest<{ activity: ActivityRecord }>(auth, `/api/v1/activities/${encodeURIComponent(activityId)}`); + return result.activity; +} + export type MonitoringSshHostRecord = { id: string; userId?: string; diff --git a/apps/web/src/lib/queryClient.ts b/apps/web/src/lib/queryClient.ts index f40b0097..f0f2171f 100644 --- a/apps/web/src/lib/queryClient.ts +++ b/apps/web/src/lib/queryClient.ts @@ -63,6 +63,11 @@ export const queryKeys = { tokens: (token: string | null) => ["notifications", token, "tokens"] as const, forwarders: (token: string | null) => ["notifications", token, "forwarders"] as const, }, + activities: { + root: ["activities"] as const, + list: (query: Record = {}) => ["activities", "list", query] as const, + detail: (activityId: string) => ["activities", "detail", activityId] as const, + }, settings: { widgets: (token: string | null) => ["settings", token, "widgets"] as const, glanceables: (token: string | null) => ["settings", token, "glanceables"] as const, diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index c855c0a6..e2cc1689 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -11,6 +11,7 @@ const AuthenticatedLayout = lazy(() => import("./app/(authenticated)/layout")); const OnboardingPage = lazy(() => import("./app/(authenticated)/onboarding/page")); const DynamicPage = lazy(() => import("./app/(authenticated)/dashboard/[page]/page")); const NewsPage = lazy(() => import("./app/(authenticated)/apps/news/page")); +const ActivityHistoryPage = lazy(() => import("./app/(authenticated)/apps/activity/page")); const NewsLayout = lazy(() => import("./components/news/NewsLayout")); const NotificationsPage = lazy(() => import("./app/(authenticated)/apps/monitoring/notifications/page")); const NotificationsInboxPage = lazy(() => import("./app/(authenticated)/apps/notifications/inbox/page")); @@ -67,7 +68,9 @@ export const appRouter = createBrowserRouter([ element: , children: [ { path: "home", element: }, - { path: "news", element: }, + { path: "news", element: }, + { path: "apps/activity", element: }, + { path: "apps/activity/:activityId", element: }, { path: "apps/news", element: ( From a349f28f7790b27dfc16ac1f23c2fb9d5526410a Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 10 Oct 2026 00:04:10 +0200 Subject: [PATCH 06/18] Add trusted internal activity publisher --- apps/backend/src/lib/data/activities.ts | 26 +++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/lib/data/activities.ts b/apps/backend/src/lib/data/activities.ts index b231435f..a4c3dd70 100644 --- a/apps/backend/src/lib/data/activities.ts +++ b/apps/backend/src/lib/data/activities.ts @@ -135,8 +135,6 @@ export async function publishIntegrationActivity( integrationId: string, untrustedInput: unknown, ) { - const input = validatePublishActivityInput(untrustedInput); - enforcePublishRateLimit(userId); const pb = await getSuperuserPB(); const integration = await pb.collection("integrations").getOne(integrationId).catch(() => null); if (!integration || relationId(integration.user) !== userId) { @@ -146,18 +144,34 @@ export async function publishIntegrationActivity( const sourceId = integration.id; const rawSource = typeof integration.source === "string" ? integration.source.trim() : ""; const source = rawSource.slice(0, 120) || "integration"; + return publishActivity(userId, source, untrustedInput, sourceId); +} + +/** Publish a validated activity for a trusted server-side producer. */ +export async function publishActivity( + userId: string, + source: string, + untrustedInput: unknown, + sourceId?: string, +) { + const input = validatePublishActivityInput(untrustedInput); + enforcePublishRateLimit(userId); + const normalizedSource = source.trim().slice(0, 120); + if (!normalizedSource) throw new ActivityInputError("Activity source is required"); + const normalizedSourceId = sourceId?.trim().slice(0, 200); + const pb = await getSuperuserPB(); const rawKey = input.idempotencyKey ?? input.eventId; const idempotencyKey = rawKey; if (idempotencyKey) { - const existing = await findDuplicate(pb, userId, source, sourceId, idempotencyKey); + const existing = await findDuplicate(pb, userId, normalizedSource, normalizedSourceId ?? "", idempotencyKey); if (existing) return { activity: mapActivity(existing), duplicate: true }; } const payload = { owner: userId, - source, - sourceId, + source: normalizedSource, + ...(normalizedSourceId ? { sourceId: normalizedSourceId } : {}), ...(input.eventId ? { eventId: input.eventId } : {}), ...(idempotencyKey ? { idempotencyKey } : {}), type: input.type, @@ -175,7 +189,7 @@ export async function publishIntegrationActivity( return { activity: mapActivity(created), duplicate: false }; } catch (error) { if (idempotencyKey) { - const existing = await findDuplicate(pb, userId, source, sourceId, idempotencyKey); + const existing = await findDuplicate(pb, userId, normalizedSource, normalizedSourceId ?? "", idempotencyKey); if (existing) return { activity: mapActivity(existing), duplicate: true }; } throw error; From 622e03d5b6c0b9e6fa1e228c9320c26ab674b633 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 10 Oct 2026 00:04:42 +0200 Subject: [PATCH 07/18] Refresh activity queries from socket snapshots --- apps/web/src/context/ActivityContext.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/context/ActivityContext.tsx b/apps/web/src/context/ActivityContext.tsx index 7ca7647a..c2afc360 100644 --- a/apps/web/src/context/ActivityContext.tsx +++ b/apps/web/src/context/ActivityContext.tsx @@ -86,6 +86,7 @@ export function ActivityProvider({ children }: { children: ReactNode }) { return; } if (message.type !== "activity:snapshot") return; + void queryClient.invalidateQueries({ queryKey: ["api", token, ...queryKeys.activities.root] }); const nextNotifications = Array.isArray(message.notifications) ? message.notifications : []; setNotifications(nextNotifications); queryClient.setQueryData(["api", token, ...queryKeys.notifications.items(token)], nextNotifications); From 007241e0a7d5611b214fa86ede72227770ca8c4e Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 10 Oct 2026 00:06:39 +0200 Subject: [PATCH 08/18] Add activities to PocketBase client types --- packages/types/pocketbase/pocketbase-types.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/types/pocketbase/pocketbase-types.ts b/packages/types/pocketbase/pocketbase-types.ts index 04102df8..e7db3c34 100644 --- a/packages/types/pocketbase/pocketbase-types.ts +++ b/packages/types/pocketbase/pocketbase-types.ts @@ -12,6 +12,7 @@ export const Collections = { Otps: "_otps", Superusers: "_superusers", AppInfo: "appInfo", + Activities: "activities", Integrations: "integrations", JobLogs: "jobLogs", LinkItems: "linkItems", @@ -121,6 +122,23 @@ export type AppInfoRecord = { version?: string } +export type ActivitiesRecord = { + action?: null | Record + createdAt?: IsoAutoDateString + description?: string + eventId?: string + id: string + idempotencyKey?: string + metadata?: null | Record + occurredAt: IsoDateString + owner: RecordIdString + severity?: "info" | "success" | "warning" | "error" + source: string + sourceId?: string + title: string + type: string +} + export const IntegrationsTypeOptions = { "plugin": "plugin", "caldav": "caldav", @@ -391,6 +409,7 @@ export type MfasResponse = Required & BaseSystemF export type OtpsResponse = Required & BaseSystemFields export type SuperusersResponse = Required & AuthSystemFields export type AppInfoResponse = Required & BaseSystemFields +export type ActivitiesResponse = Required & BaseSystemFields export type IntegrationsResponse = Required> & BaseSystemFields export type JobLogsResponse = Required & BaseSystemFields export type LinkItemsResponse = Required & BaseSystemFields @@ -420,6 +439,7 @@ export type CollectionRecords = { _otps: OtpsRecord _superusers: SuperusersRecord appInfo: AppInfoRecord + activities: ActivitiesRecord integrations: IntegrationsRecord jobLogs: JobLogsRecord linkItems: LinkItemsRecord @@ -448,6 +468,7 @@ export type CollectionResponses = { _otps: OtpsResponse _superusers: SuperusersResponse appInfo: AppInfoResponse + activities: ActivitiesResponse integrations: IntegrationsResponse jobLogs: JobLogsResponse linkItems: LinkItemsResponse From 0d522fc2f688c083ce6e2e105527f137886e753c Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 10 Oct 2026 00:07:37 +0200 Subject: [PATCH 09/18] test: cover activity backend ownership and retention --- .../src/lib/data/activities.service.test.ts | 189 ++++++++++++++++++ .../src/routes/activities.route.test.ts | 23 +++ 2 files changed, 212 insertions(+) create mode 100644 apps/backend/src/lib/data/activities.service.test.ts create mode 100644 apps/backend/src/routes/activities.route.test.ts diff --git a/apps/backend/src/lib/data/activities.service.test.ts b/apps/backend/src/lib/data/activities.service.test.ts new file mode 100644 index 00000000..bd264286 --- /dev/null +++ b/apps/backend/src/lib/data/activities.service.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { registerDashwiseSDKConnector } from "../pb/pocketbase"; +import { subscribeActivity } from "../activity"; +import { + deleteActivity, + deleteExpiredActivities, + getActivity, + listActivities, + publishActivity, + publishIntegrationActivity, +} from "./activities"; + +type Row = Record & { id: string; owner: string; occurredAt: string }; + +function createPocketBaseFixture() { + const rows: Row[] = []; + const integrations = new Map(); + let nextId = 1; + const calls: Array<{ collection: string; method: string; filter?: string }> = []; + + const pb = { + collection(name: string) { + if (name === "integrations") { + return { + async getOne(id: string) { + calls.push({ collection: name, method: "getOne" }); + const integration = integrations.get(id); + if (!integration) throw new Error("not found"); + return integration; + }, + }; + } + if (name !== "activities") throw new Error(`Unexpected collection ${name}`); + return { + async create(payload: Record) { + calls.push({ collection: name, method: "create" }); + const now = new Date().toISOString(); + const row = { ...payload, id: `activity-${nextId++}`, created: now, createdAt: now } as Row; + rows.push(row); + return row; + }, + async getOne(id: string) { + calls.push({ collection: name, method: "getOne" }); + const row = rows.find((item) => item.id === id); + if (!row) throw new Error("not found"); + return row; + }, + async getFirstListItem(filter: string) { + calls.push({ collection: name, method: "getFirstListItem", filter }); + const row = rows.find((item) => filter.includes(`owner = "${item.owner}"`) + && filter.includes(`source = "${item.source}"`) + && filter.includes(`sourceId = "${item.sourceId ?? ""}"`) + && filter.includes(`idempotencyKey = "${item.idempotencyKey}"`)); + if (!row) throw new Error("not found"); + return row; + }, + async getList(page: number, perPage: number, options: { filter: string; fields?: string; sort?: string }) { + calls.push({ collection: name, method: "getList", filter: options.filter }); + let matching = rows.filter((item) => { + const ownerMatch = /owner = "([^"]+)"/.exec(options.filter); + if (ownerMatch && item.owner !== ownerMatch[1]) return false; + const sourceMatch = /source = "([^"]+)"/.exec(options.filter); + if (sourceMatch && item.source !== sourceMatch[1]) return false; + const typeMatch = /type = "([^"]+)"/.exec(options.filter); + if (typeMatch && item.type !== typeMatch[1]) return false; + const severityMatch = /severity = "([^"]+)"/.exec(options.filter); + if (severityMatch && item.severity !== severityMatch[1]) return false; + const before = /occurredAt < "([^"]+)"/.exec(options.filter); + if (before && !(item.occurredAt < before[1]!)) return false; + const from = /occurredAt >= "([^"]+)"/.exec(options.filter); + if (from && !(item.occurredAt >= from[1]!)) return false; + const to = /occurredAt <= "([^"]+)"/.exec(options.filter); + if (to && !(item.occurredAt <= to[1]!)) return false; + return true; + }); + if (options.sort === "occurredAt") matching = matching.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt)); + else matching = matching.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); + const totalItems = matching.length; + const start = (page - 1) * perPage; + const items = matching.slice(start, start + perPage); + return { + page, + perPage, + totalItems, + totalPages: Math.ceil(totalItems / perPage), + items: options.fields ? items.map(({ id, owner, occurredAt }) => ({ id, owner, occurredAt })) : items, + }; + }, + async delete(id: string) { + calls.push({ collection: name, method: "delete" }); + const index = rows.findIndex((item) => item.id === id); + if (index < 0) throw new Error("not found"); + rows.splice(index, 1); + }, + }; + }, + }; + + registerDashwiseSDKConnector({ getSuperuserClient: async () => pb } as never); + return { rows, integrations, calls }; +} + +describe("activity persistence service", () => { + let fixture: ReturnType; + let unsubscribe: Array<() => void>; + + beforeEach(() => { + fixture = createPocketBaseFixture(); + unsubscribe = []; + }); + + afterEach(() => { + for (const dispose of unsubscribe) dispose(); + }); + + test("publishes with server-resolved owner and source, deduplicates, and broadcasts only to that owner", async () => { + let ownerEvents = 0; + let otherOwnerEvents = 0; + unsubscribe.push(subscribeActivity("owner-a", async () => { ownerEvents += 1; })); + unsubscribe.push(subscribeActivity("owner-b", async () => { otherOwnerEvents += 1; })); + + const input = { + type: "service.offline", + title: "Service offline", + idempotencyKey: "service-123-offline", + }; + const first = await publishActivity("owner-a", "monitoring", input, "service-123"); + const duplicate = await publishActivity("owner-a", "monitoring", input, "service-123"); + + expect(first.duplicate).toBe(false); + expect(first.activity).toMatchObject({ ownerId: "owner-a", source: "monitoring", sourceId: "service-123" }); + expect(duplicate).toMatchObject({ duplicate: true, activity: { id: first.activity.id } }); + expect(fixture.rows).toHaveLength(1); + expect(fixture.rows[0]).toMatchObject({ owner: "owner-a", source: "monitoring", sourceId: "service-123" }); + expect(ownerEvents).toBe(1); + expect(otherOwnerEvents).toBe(0); + }); + + test("resolves integration attribution and rejects publishing through another user's integration", async () => { + fixture.integrations.set("integration-a", { id: "integration-a", user: "owner-a", source: "nextcloud" }); + + const published = await publishIntegrationActivity("owner-a", "integration-a", { + type: "service.offline", + title: "Nextcloud is offline", + eventId: "offline-1", + }); + expect(published.activity).toMatchObject({ ownerId: "owner-a", source: "nextcloud", sourceId: "integration-a" }); + + await expect(publishIntegrationActivity("owner-b", "integration-a", { + type: "service.offline", + title: "Spoofed producer", + })).rejects.toMatchObject({ status: 404 }); + expect(fixture.rows).toHaveLength(1); + }); + + test("scopes get, delete, and list operations to the authenticated owner", async () => { + const ownerA = await publishActivity("owner-a", "monitoring", { type: "service.online", title: "A" }); + const ownerB = await publishActivity("owner-b", "monitoring", { type: "service.online", title: "B" }); + + expect(await getActivity("owner-a", ownerB.activity.id)).toBeNull(); + expect(await deleteActivity("owner-a", ownerB.activity.id)).toBe(false); + expect(await getActivity("owner-a", ownerA.activity.id)).toMatchObject({ ownerId: "owner-a" }); + + const page = await listActivities("owner-a", { page: 1, perPage: 10, source: "monitoring" }); + expect(page.items.map((item) => item.ownerId)).toEqual(["owner-a"]); + expect(fixture.calls.filter((call) => call.method === "getList").at(-1)?.filter) + .toContain('owner = "owner-a"'); + }); + + test("retention deletes only expired records, broadcasts affected owners, and keeps recent events", async () => { + const expiry = Date.now() - 5 * 24 * 60 * 60 * 1_000; + await publishActivity("owner-a", "monitoring", { + type: "old.event", title: "Old", occurredAt: new Date(expiry).toISOString(), + }); + const recent = await publishActivity("owner-b", "monitoring", { + type: "new.event", title: "Recent", occurredAt: new Date().toISOString(), + }); + let ownerAEvents = 0; + let ownerBEvents = 0; + unsubscribe.push(subscribeActivity("owner-a", async () => { ownerAEvents += 1; })); + unsubscribe.push(subscribeActivity("owner-b", async () => { ownerBEvents += 1; })); + + const result = await deleteExpiredActivities(2); + expect(result).toEqual({ deleted: 1 }); + expect(fixture.rows.map((row) => row.id)).toEqual([recent.activity.id]); + expect(ownerAEvents).toBe(1); + expect(ownerBEvents).toBe(0); + }); +}); diff --git a/apps/backend/src/routes/activities.route.test.ts b/apps/backend/src/routes/activities.route.test.ts new file mode 100644 index 00000000..9fce8561 --- /dev/null +++ b/apps/backend/src/routes/activities.route.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import activitiesRoute from "./activities.route"; + +describe("activity API authentication", () => { + test("requires authentication for list, detail, delete, and producer routes", async () => { + const requests = [ + new Request("http://localhost/api/v1/activities"), + new Request("http://localhost/api/v1/activities/activity-1"), + new Request("http://localhost/api/v1/activities/activity-1", { method: "DELETE" }), + new Request("http://localhost/api/v1/integrations/integration-1/activities", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "service.offline", title: "Service offline" }), + }), + ]; + + for (const request of requests) { + const response = await activitiesRoute.fetch(request); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: "Unauthorized" }); + } + }); +}); From a2b186a13b67b021fedb8c963ef0e2ccc260e932 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 10 Oct 2026 00:07:56 +0200 Subject: [PATCH 10/18] Add integration activity publishing SDK --- bun.lock | 1 + packages/integrationskit/README.md | 27 ++ packages/integrationskit/activities.test.ts | 64 +++++ packages/integrationskit/activities.ts | 259 ++++++++++++++++++++ packages/integrationskit/package.json | 2 + 5 files changed, 353 insertions(+) create mode 100644 packages/integrationskit/activities.test.ts create mode 100644 packages/integrationskit/activities.ts diff --git a/bun.lock b/bun.lock index be27e644..fc07b2eb 100644 --- a/bun.lock +++ b/bun.lock @@ -149,6 +149,7 @@ "version": "1.0.0", "dependencies": { "@dashwise/app-icon": "workspace:*", + "@dashwise/types": "workspace:*", "@iconify-icon/react": "^3.0.3", "@radix-ui/react-progress": "^1.1.7", "date-fns": "^4.1.0", diff --git a/packages/integrationskit/README.md b/packages/integrationskit/README.md index 555a28ce..1ef57a04 100644 --- a/packages/integrationskit/README.md +++ b/packages/integrationskit/README.md @@ -106,6 +106,33 @@ Preview mode is used when `isPreview=true`: - runtime data is not fetched from live APIs - widget resolution still runs and can render fallback/example placeholder values +## Publishing activities + +Integrations can publish meaningful events through the typed activity client. It sends an authenticated request to the integration-scoped API; Dashwise checks that the signed-in user owns that integration and derives the activity owner and source on the server. + +```ts +import { createActivityPublisher } from "@dashwise/integrationskit/activities"; + +const activities = createActivityPublisher({ + baseUrl: "https://dashwise.example.com", + integrationId: "your-integration-record-id", + // Supply the current Dashwise user session token. Never put it in integration YAML. + token: () => getCurrentDashwiseSessionToken(), +}); + +await activities.publish({ + type: "service.offline", + title: "Nextcloud is offline", + severity: "warning", + idempotencyKey: "nextcloud:offline:2026-10-10T09:30Z", + metadata: { serviceId: "nextcloud" }, +}); +``` + +`baseUrl` is the Dashwise backend origin and may also end in `/api/v1`. The client adds the integration activity path, validates payload bounds and safe internal action targets, and returns `{ activity, duplicate }`. HTTP errors are thrown as `ActivityPublishError` with an optional `status` and `responseBody`; network failures are also wrapped in that error type. Reuse a stable `idempotencyKey` when retrying the same event. + +The SDK does not accept owner or source fields. Publishing requires a valid Dashwise user session and the referenced integration must belong to that user. Do not include credentials, secrets, or large resource contents in metadata. Activity publication is best-effort from producers; callers should catch `ActivityPublishError` where a publishing failure must not interrupt the main integration work. + ## Why this package exists `integrationskit` keeps integration rendering logic self-contained and declarative. diff --git a/packages/integrationskit/activities.test.ts b/packages/integrationskit/activities.test.ts new file mode 100644 index 00000000..dcf0c464 --- /dev/null +++ b/packages/integrationskit/activities.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { ActivityPublishError, createActivityPublisher, validatePublishActivityInput } from "./activities"; + +const validInput = { + type: "service.offline", + title: "Nextcloud is offline", + severity: "warning" as const, + metadata: { serviceId: "nextcloud" }, +}; + +describe("activity publishing SDK", () => { + test("validates a payload and rejects ownership/source fields", () => { + expect(validatePublishActivityInput(validInput)).toEqual(validInput); + expect(() => validatePublishActivityInput({ ...validInput, ownerId: "another-user" })).toThrow(ActivityPublishError); + expect(() => validatePublishActivityInput({ ...validInput, source: "spoofed-source" })).toThrow(ActivityPublishError); + }); + + test("rejects unsafe actions, credential metadata, and oversized metadata", () => { + expect(() => validatePublishActivityInput({ ...validInput, action: { label: "Open", target: "https://evil.example" } })).toThrow(/safe internal path/); + expect(() => validatePublishActivityInput({ ...validInput, metadata: { apiKey: "do-not-store" } })).toThrow(/credentials or secrets/); + expect(() => validatePublishActivityInput({ ...validInput, metadata: { payload: "x".repeat(17 * 1024) } })).toThrow(/16 KB limit/); + }); + + test("posts with the authenticated integration route and returns the server result", async () => { + let requestedUrl = ""; + let requestedInit: RequestInit | undefined; + const result = { + activity: { id: "activity-1", ownerId: "user-1" }, + duplicate: false, + }; + const publisher = createActivityPublisher({ + baseUrl: "https://dashwise.example.com/api/v1/", + integrationId: "integration / 1", + token: async () => "session-token", + fetch: async (input, init) => { + requestedUrl = String(input); + requestedInit = init; + return Response.json(result, { status: 201 }); + }, + }); + + await expect(publisher.publish(validInput)).resolves.toEqual(result); + expect(requestedUrl).toBe("https://dashwise.example.com/api/v1/integrations/integration%20%2F%201/activities"); + expect(requestedInit?.method).toBe("POST"); + expect(new Headers(requestedInit?.headers).get("authorization")).toBe("Bearer session-token"); + expect(JSON.parse(String(requestedInit?.body))).toEqual(validInput); + }); + + test("surfaces API status and response errors", async () => { + const publisher = createActivityPublisher({ + baseUrl: "https://dashwise.example.com", + integrationId: "integration-1", + token: "session-token", + fetch: async () => Response.json({ error: "Unauthorized" }, { status: 401 }), + }); + + await expect(publisher.publish(validInput)).rejects.toMatchObject({ + name: "ActivityPublishError", + message: "Unauthorized", + status: 401, + responseBody: { error: "Unauthorized" }, + }); + }); +}); diff --git a/packages/integrationskit/activities.ts b/packages/integrationskit/activities.ts new file mode 100644 index 00000000..f049a31e --- /dev/null +++ b/packages/integrationskit/activities.ts @@ -0,0 +1,259 @@ +import type { ActivityRecord, PublishActivityInput } from "@dashwise/types"; + +const MAX_REQUEST_BYTES = 100 * 1024; +const MAX_METADATA_BYTES = 16 * 1024; +const MAX_METADATA_DEPTH = 8; +const SEVERITIES = new Set(["info", "success", "warning", "error"]); +const ALLOWED_FIELDS = new Set([ + "type", + "title", + "description", + "severity", + "eventId", + "idempotencyKey", + "occurredAt", + "metadata", + "action", +]); + +export type PublishActivityResult = { + activity: ActivityRecord; + duplicate: boolean; +}; + +export type ActivityPublisherOptions = { + /** Dashwise backend origin, with or without a trailing `/api/v1`. */ + baseUrl: string; + /** The integration record whose ownership the authenticated user controls. */ + integrationId: string; + /** Dashwise user session token. The backend verifies that this user owns the integration. */ + token: string | (() => string | Promise); + fetch?: typeof fetch; +}; + +export class ActivityPublishError extends Error { + readonly status?: number; + readonly responseBody?: unknown; + readonly cause?: unknown; + + constructor(message: string, options: { status?: number; responseBody?: unknown; cause?: unknown } = {}) { + super(message); + this.name = "ActivityPublishError"; + this.status = options.status; + this.responseBody = options.responseBody; + this.cause = options.cause; + } +} + +/** + * Creates an integration-scoped activity publisher. Source and owner are resolved + * by Dashwise from the authenticated user and integration record; they are never + * accepted from the activity payload. + */ +export function createActivityPublisher(options: ActivityPublisherOptions) { + const baseUrl = normalizeBaseUrl(options.baseUrl); + const integrationId = options.integrationId.trim(); + if (!integrationId) throw new ActivityPublishError("An integration ID is required"); + + const fetchImpl = options.fetch ?? globalThis.fetch; + if (typeof fetchImpl !== "function") throw new ActivityPublishError("A Fetch API implementation is required"); + + return { + async publish(input: PublishActivityInput): Promise { + const validated = validatePublishActivityInput(input); + const token = (typeof options.token === "function" ? await options.token() : options.token).trim(); + if (!token) throw new ActivityPublishError("A Dashwise session token is required"); + + const url = `${baseUrl}/api/v1/integrations/${encodeURIComponent(integrationId)}/activities`; + let response: Response; + try { + response = await fetchImpl(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(validated), + }); + } catch (cause) { + throw new ActivityPublishError("Unable to reach the Dashwise activity API", { cause }); + } + + const body = await readResponseBody(response); + if (!response.ok) { + const message = getErrorMessage(body) ?? `Activity publishing failed with status ${response.status}`; + throw new ActivityPublishError(message, { status: response.status, responseBody: body }); + } + + if (!isPublishActivityResult(body)) { + throw new ActivityPublishError("The Dashwise activity API returned an invalid response", { + status: response.status, + responseBody: body, + }); + } + return body; + }, + }; +} + +/** Validate activity payloads locally using the same limits as the backend. */ +export function validatePublishActivityInput(value: unknown): PublishActivityInput { + if (!isPlainObject(value)) throw new ActivityPublishError("Activity payload must be an object"); + if (Object.keys(value).some((key) => !ALLOWED_FIELDS.has(key))) { + throw new ActivityPublishError("Activity payload contains unsupported fields"); + } + + const type = requiredString(value.type, "type", 120); + const title = requiredString(value.title, "title", 200); + const description = optionalString(value.description, "description", 2_000); + const severity = value.severity; + if (severity !== undefined && (typeof severity !== "string" || !SEVERITIES.has(severity))) { + throw new ActivityPublishError("Activity severity must be info, success, warning, or error"); + } + const eventId = optionalString(value.eventId, "eventId", 200); + const idempotencyKey = optionalString(value.idempotencyKey, "idempotencyKey", 256); + const occurredAt = optionalString(value.occurredAt, "occurredAt", 64); + if (occurredAt && (!/(?:Z|[+-]\d{2}:\d{2})$/.test(occurredAt) || !Number.isFinite(Date.parse(occurredAt)))) { + throw new ActivityPublishError("Activity occurredAt must be a valid ISO date with a timezone"); + } + + let metadata: Record | undefined; + if (value.metadata !== undefined) { + if (!isPlainObject(value.metadata)) throw new ActivityPublishError("Activity metadata must be an object"); + if (!isJsonValue(value.metadata)) throw new ActivityPublishError("Activity metadata must contain only JSON values"); + if (byteLength(JSON.stringify(value.metadata)) > MAX_METADATA_BYTES) { + throw new ActivityPublishError("Activity metadata exceeds the 16 KB limit"); + } + if (hasExcessiveDepth(value.metadata, 0)) { + throw new ActivityPublishError("Activity metadata nesting exceeds the limit"); + } + if (hasSensitiveMetadataKey(value.metadata)) { + throw new ActivityPublishError("Activity metadata must not contain credentials or secrets"); + } + metadata = value.metadata as Record; + } + + let action: PublishActivityInput["action"]; + if (value.action !== undefined) { + if (!isPlainObject(value.action) || Object.keys(value.action).some((key) => key !== "label" && key !== "target")) { + throw new ActivityPublishError("Activity action must contain only a label and target"); + } + const label = requiredString(value.action.label, "action.label", 80); + const target = requiredString(value.action.target, "action.target", 2_048); + if (!isSafeInternalTarget(target)) { + throw new ActivityPublishError("Activity actions must target a safe internal path"); + } + action = { label, target }; + } + + const normalized: PublishActivityInput = { + type, + title, + ...(description !== undefined ? { description } : {}), + ...(severity !== undefined ? { severity: severity as PublishActivityInput["severity"] } : {}), + ...(eventId !== undefined ? { eventId } : {}), + ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), + ...(occurredAt !== undefined ? { occurredAt } : {}), + ...(metadata !== undefined ? { metadata: metadata as PublishActivityInput["metadata"] } : {}), + ...(action !== undefined ? { action } : {}), + }; + + if (byteLength(JSON.stringify(normalized)) > MAX_REQUEST_BYTES) { + throw new ActivityPublishError("Activity payload exceeds the 100 KB limit"); + } + return normalized; +} + +function normalizeBaseUrl(value: string) { + const trimmed = value.trim().replace(/\/+$/, "").replace(/\/api\/v1$/i, ""); + if (!/^https?:\/\//i.test(trimmed)) throw new ActivityPublishError("baseUrl must be an absolute HTTP(S) URL"); + try { + const parsed = new URL(trimmed); + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error("Invalid URL components"); + } + return parsed.toString().replace(/\/$/, ""); + } catch { + throw new ActivityPublishError("baseUrl must be an absolute HTTP(S) URL"); + } +} + +function requiredString(value: unknown, name: string, maxLength: number) { + if (typeof value !== "string" || !value.trim() || value.trim().length > maxLength) { + throw new ActivityPublishError(`${name} must be a non-empty string up to ${maxLength} characters`); + } + return value.trim(); +} + +function optionalString(value: unknown, name: string, maxLength: number) { + if (value === undefined) return undefined; + if (typeof value !== "string" || value.trim().length > maxLength) { + throw new ActivityPublishError(`${name} must be a string up to ${maxLength} characters`); + } + const trimmed = value.trim(); + if (!trimmed && ["eventId", "idempotencyKey", "occurredAt"].includes(name)) { + throw new ActivityPublishError(`${name} must not be empty`); + } + return trimmed; +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isJsonValue(value: unknown): value is null | string | number | boolean | unknown[] | Record { + if (value === null || typeof value === "string" || typeof value === "boolean") return true; + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) return value.every(isJsonValue); + if (isPlainObject(value)) return Object.values(value).every(isJsonValue); + return false; +} + +function hasExcessiveDepth(value: unknown, depth: number): boolean { + if (depth > MAX_METADATA_DEPTH) return true; + if (Array.isArray(value)) return value.some((item) => hasExcessiveDepth(item, depth + 1)); + if (!isPlainObject(value)) return false; + return Object.values(value).some((item) => hasExcessiveDepth(item, depth + 1)); +} + +function hasSensitiveMetadataKey(value: unknown): boolean { + if (Array.isArray(value)) return value.some(hasSensitiveMetadataKey); + if (!isPlainObject(value)) return false; + return Object.entries(value).some(([key, item]) => + /(^|[_-])(password|passwd|secret|token|access[_-]?token|refresh[_-]?token|api[_-]?key|auth|authorization|credential|private[_-]?key)([_-]|$)/i.test(key.replace(/([a-z0-9])([A-Z])/g, "$1_$2")) || hasSensitiveMetadataKey(item), + ); +} + +function isSafeInternalTarget(target: string) { + if (!target.startsWith("/") || target.startsWith("//") || target.includes("\\") || /[\u0000-\u001f\u007f]/.test(target)) return false; + try { + const parsed = new URL(target, "https://dashwise.invalid"); + return parsed.origin === "https://dashwise.invalid" && parsed.pathname.startsWith("/"); + } catch { + return false; + } +} + +function byteLength(value: string) { + return new TextEncoder().encode(value).byteLength; +} + +async function readResponseBody(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +function getErrorMessage(body: unknown) { + return isPlainObject(body) && typeof body.error === "string" ? body.error : undefined; +} + +function isPublishActivityResult(value: unknown): value is PublishActivityResult { + if (!isPlainObject(value) || !isPlainObject(value.activity) || typeof value.duplicate !== "boolean") return false; + return typeof value.activity.id === "string" && typeof value.activity.ownerId === "string"; +} diff --git a/packages/integrationskit/package.json b/packages/integrationskit/package.json index e5fa504a..d998928e 100644 --- a/packages/integrationskit/package.json +++ b/packages/integrationskit/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@dashwise/app-icon": "workspace:*", + "@dashwise/types": "workspace:*", "@iconify-icon/react": "^3.0.3", "@radix-ui/react-progress": "^1.1.7", "date-fns": "^4.1.0", @@ -23,6 +24,7 @@ "./GlanceableComponent": "./Glanceable.tsx", "./Shortcuts": "./Shortcuts.tsx", "./Widget": "./Widget.tsx", + "./activities": "./activities.ts", "./forms/WidgetPropertiesForm": { "types": "./forms/WidgetPropertiesForm.tsx", "import": "./forms/WidgetPropertiesForm.tsx", From a1c7e7b858cd1b6b3a0267c9ca4f2a4ecdfbc67d Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 10 Oct 2026 00:07:58 +0200 Subject: [PATCH 11/18] Publish monitoring and news activity transitions --- apps/backend/src/jobs/monitoring/runner.ts | 33 +++++++++++++++++++++ apps/backend/src/jobs/news/feed-builder.ts | 34 ++++++++++++++++++++-- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/jobs/monitoring/runner.ts b/apps/backend/src/jobs/monitoring/runner.ts index ba981e2e..85f5a8e1 100644 --- a/apps/backend/src/jobs/monitoring/runner.ts +++ b/apps/backend/src/jobs/monitoring/runner.ts @@ -7,6 +7,7 @@ import { updateMonitoringJob, } from "../../lib/data/superuser"; import { createNotificationByTopicId } from "../../lib/data/notifications/publish"; +import { publishActivity } from "../../lib/data/activities"; import { createLogger } from "../../lib/logger"; import { getLinkIdFromSource, getLinkSource, parseConfigObject, type StatusCheckMethod } from "./shared"; @@ -46,6 +47,33 @@ type AvgLatencyInfo = { const logger = createLogger("Monitoring"); +async function publishMonitorTransitionActivity( + job: Record, + previousStatus: string, + status: string, + occurredAt: string, +) { + const ownerId = String(job.userId || "").trim(); + const monitorId = String(job.id || "").trim(); + if (!ownerId || !monitorId) return; + + const recovered = status === "healthy"; + try { + await publishActivity(ownerId, "monitoring", { + type: "monitor.availability_changed", + title: recovered ? "A monitored service recovered" : "A monitored service is unavailable", + description: recovered ? "A monitored service is responding again." : "A monitored service stopped responding.", + severity: recovered ? "success" : "warning", + occurredAt, + eventId: `${monitorId}:${previousStatus}:${status}:${occurredAt}`, + metadata: { monitorId, previousStatus, status }, + }, monitorId); + } catch { + // Activity persistence is best-effort and must not interrupt monitoring. + logger.warn("Could not publish monitor activity", { jobId: monitorId }); + } +} + export async function runStatusMonitoringJobs(): Promise<{ processed: number; skipped: number; @@ -179,6 +207,8 @@ export async function runStatusMonitoringJobsWithOptions(options?: { ...latencyUpdate.payload, }); + await publishMonitorTransitionActivity(job, currentStatus, newStatus, pingTimestamp); + if (job.notifyOnStatusChange && job.notifyTopicId) { try { const content = `Monitor "${job.title || job.endpoint || job.source || 'Unnamed'}" changed from ${currentStatus} to ${newStatus}`; @@ -229,11 +259,14 @@ export async function runStatusMonitoringJobsWithOptions(options?: { }, ]; + const occurredAt = new Date().toISOString(); await updateMonitoringJob(job.id, { status: 'unhealthy', pings: updatedPings, }); + await publishMonitorTransitionActivity(job, currentStatus, 'unhealthy', occurredAt); + if (job.notifyOnStatusChange && job.notifyTopicId) { try { const content = `Monitor "${job.title || job.endpoint || job.source || 'Unnamed'}" changed from ${currentStatus} to unhealthy (fetch error)`; diff --git a/apps/backend/src/jobs/news/feed-builder.ts b/apps/backend/src/jobs/news/feed-builder.ts index 205cffe5..ec329449 100644 --- a/apps/backend/src/jobs/news/feed-builder.ts +++ b/apps/backend/src/jobs/news/feed-builder.ts @@ -23,6 +23,7 @@ import { } from "../../lib/cache/feed-items"; import { config } from "../../lib/config"; import { createLogger } from "../../lib/logger"; +import { publishActivity } from "../../lib/data/activities"; import { getFeedItems } from "./helper"; import type { NewsFeedItem } from "@dashwise/types/sdk"; @@ -43,6 +44,32 @@ type BuilderOptions = { userId?: string; feedIds?: string[] }; const logger = createLogger("NewsFeedBuilder"); +async function publishNewsFeedTransitionActivity( + subscription: NewsSubscription, + transition: "unavailable" | "recovered", +) { + const ownerId = String(subscription.userId || "").trim(); + const subscriptionId = String(subscription.id || "").trim(); + if (!ownerId || !subscriptionId) return; + + const occurredAt = new Date().toISOString(); + const recovered = transition === "recovered"; + try { + await publishActivity(ownerId, "news", { + type: recovered ? "news.feed_recovered" : "news.feed_unavailable", + title: recovered ? "A news feed recovered" : "A news feed is unavailable", + description: recovered ? "A news feed is updating again." : "A news feed could not be updated.", + severity: recovered ? "success" : "warning", + occurredAt, + eventId: `${subscriptionId}:${transition}:${occurredAt}`, + metadata: { subscriptionId }, + }, subscriptionId); + } catch { + // Activity persistence is best-effort and must not interrupt feed updates. + logger.warn("Could not publish news feed activity", { subscriptionId }); + } +} + function itemTime(item: Record) { const value = item.pubDate; const time = value instanceof Date ? value.getTime() : new Date(String(value || "")).getTime(); @@ -94,6 +121,7 @@ async function fetchAndCacheSubscription(subscription: NewsSubscription, result: const id = String(subscription.id || ""); const feedUrl = String(subscription.url || subscription.feedUrl || ""); if (!id || !feedUrl) return false; + const wasFailing = Boolean(String(subscription.fetchErrors || "").trim()); try { const raw = await getFeedItems({ @@ -106,13 +134,15 @@ async function fetchAndCacheSubscription(subscription: NewsSubscription, result: }); const articles = deduplicateSubscriptionArticles(raw as unknown as Record[], subscription); await writeSubscriptionArticles(id, articles); - await updateNewsSubscription(id, { fetchErrors: "" }); + const updated = await updateNewsSubscription(id, { fetchErrors: "" }); + if (updated && wasFailing) await publishNewsFeedTransitionActivity(subscription, "recovered"); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); result.errors++; result.details.push({ subscriptionId: id, action: "feed_fetch_error", error: message }); - await updateNewsSubscription(id, { fetchErrors: message }).catch(() => undefined); + const updated = await updateNewsSubscription(id, { fetchErrors: message }).catch(() => null); + if (updated && !wasFailing) await publishNewsFeedTransitionActivity(subscription, "unavailable"); logger.error(`Error fetching feed "${subscription.title || feedUrl}": ${message}`); // A failed fetch deliberately leaves the previous sorted-set cache intact. return false; From b02922dda9d58218487374d3c97cf1872da302b7 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 10 Oct 2026 00:08:22 +0200 Subject: [PATCH 12/18] test: cover activity idempotency race recovery --- .../src/lib/data/activities.service.test.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/lib/data/activities.service.test.ts b/apps/backend/src/lib/data/activities.service.test.ts index bd264286..dc299d82 100644 --- a/apps/backend/src/lib/data/activities.service.test.ts +++ b/apps/backend/src/lib/data/activities.service.test.ts @@ -16,6 +16,7 @@ function createPocketBaseFixture() { const rows: Row[] = []; const integrations = new Map(); let nextId = 1; + let raceIdempotencyOnce = false; const calls: Array<{ collection: string; method: string; filter?: string }> = []; const pb = { @@ -36,6 +37,11 @@ function createPocketBaseFixture() { calls.push({ collection: name, method: "create" }); const now = new Date().toISOString(); const row = { ...payload, id: `activity-${nextId++}`, created: now, createdAt: now } as Row; + if (raceIdempotencyOnce && typeof payload.idempotencyKey === "string") { + raceIdempotencyOnce = false; + rows.push(row); + throw new Error("UNIQUE constraint failed: activities.idempotencyKey"); + } rows.push(row); return row; }, @@ -97,7 +103,12 @@ function createPocketBaseFixture() { }; registerDashwiseSDKConnector({ getSuperuserClient: async () => pb } as never); - return { rows, integrations, calls }; + return { + rows, + integrations, + calls, + simulateIdempotencyRace() { raceIdempotencyOnce = true; }, + }; } describe("activity persistence service", () => { @@ -153,6 +164,19 @@ describe("activity persistence service", () => { expect(fixture.rows).toHaveLength(1); }); + test("recovers an idempotency race when persistence reports a unique-key conflict", async () => { + fixture.simulateIdempotencyRace(); + const result = await publishActivity("owner-a", "monitoring", { + type: "monitor.availability_changed", + title: "Service unavailable", + idempotencyKey: "monitor-1:healthy:unhealthy", + }, "monitor-1"); + + expect(result.duplicate).toBe(true); + expect(result.activity).toMatchObject({ ownerId: "owner-a", sourceId: "monitor-1" }); + expect(fixture.rows).toHaveLength(1); + }); + test("scopes get, delete, and list operations to the authenticated owner", async () => { const ownerA = await publishActivity("owner-a", "monitoring", { type: "service.online", title: "A" }); const ownerB = await publishActivity("owner-b", "monitoring", { type: "service.online", title: "B" }); From 5c84de4651e144aea39347abb45d4d893d5728b3 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 10 Oct 2026 00:08:33 +0200 Subject: [PATCH 13/18] Guard activity routes against missing owner IDs --- apps/backend/src/routes/activities.route.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/routes/activities.route.ts b/apps/backend/src/routes/activities.route.ts index 77be98a0..c1a2ae97 100644 --- a/apps/backend/src/routes/activities.route.ts +++ b/apps/backend/src/routes/activities.route.ts @@ -14,21 +14,27 @@ import { readAuthToken, readJsonBody, requireAuth, withJson } from "./shared"; const activitiesRoute = new Hono(); +async function requireActivityOwner(token: string | null) { + const { userId } = await requireAuth({ token }); + if (!userId) throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); + return userId; +} + activitiesRoute.get("/api/v1/activities", withJson(async (c) => { - const { userId } = await requireAuth({ token: readAuthToken(c) }); + const userId = await requireActivityOwner(readAuthToken(c)); const query = validateActivityQuery(c.req.query()); return listActivities(userId, query); })); activitiesRoute.get("/api/v1/activities/:id", withJson(async (c) => { - const { userId } = await requireAuth({ token: readAuthToken(c) }); + const userId = await requireActivityOwner(readAuthToken(c)); const activity = await getActivity(userId, c.req.param("id")); if (!activity) throw new ApiActionError("Activity not found", 404, { error: "Activity not found" }); return { activity }; })); activitiesRoute.delete("/api/v1/activities/:id", withJson(async (c) => { - const { userId } = await requireAuth({ token: readAuthToken(c) }); + const userId = await requireActivityOwner(readAuthToken(c)); const deleted = await deleteActivity(userId, c.req.param("id")); if (!deleted) throw new ApiActionError("Activity not found", 404, { error: "Activity not found" }); return { ok: true }; @@ -39,7 +45,7 @@ activitiesRoute.post( bodyLimit({ maxSize: 100 * 1024, onError: (c) => c.json({ error: "Activity payload exceeds the 100 KB limit" }, 413) }), async (c) => { try { - const { userId } = await requireAuth({ token: readAuthToken(c) }); + const userId = await requireActivityOwner(readAuthToken(c)); const result = await publishIntegrationActivity( userId, c.req.param("integrationId"), From 9251f6a4020372107d9607bb73e65509608a3063 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 10 Oct 2026 00:08:47 +0200 Subject: [PATCH 14/18] Validate activity route record identifiers --- apps/backend/src/routes/activities.route.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/routes/activities.route.ts b/apps/backend/src/routes/activities.route.ts index c1a2ae97..21c59dab 100644 --- a/apps/backend/src/routes/activities.route.ts +++ b/apps/backend/src/routes/activities.route.ts @@ -20,6 +20,11 @@ async function requireActivityOwner(token: string | null) { return userId; } +function requireActivityId(id: string | undefined) { + if (!id) throw new ApiActionError("Activity not found", 404, { error: "Activity not found" }); + return id; +} + activitiesRoute.get("/api/v1/activities", withJson(async (c) => { const userId = await requireActivityOwner(readAuthToken(c)); const query = validateActivityQuery(c.req.query()); @@ -28,14 +33,14 @@ activitiesRoute.get("/api/v1/activities", withJson(async (c) => { activitiesRoute.get("/api/v1/activities/:id", withJson(async (c) => { const userId = await requireActivityOwner(readAuthToken(c)); - const activity = await getActivity(userId, c.req.param("id")); + const activity = await getActivity(userId, requireActivityId(c.req.param("id"))); if (!activity) throw new ApiActionError("Activity not found", 404, { error: "Activity not found" }); return { activity }; })); activitiesRoute.delete("/api/v1/activities/:id", withJson(async (c) => { const userId = await requireActivityOwner(readAuthToken(c)); - const deleted = await deleteActivity(userId, c.req.param("id")); + const deleted = await deleteActivity(userId, requireActivityId(c.req.param("id"))); if (!deleted) throw new ApiActionError("Activity not found", 404, { error: "Activity not found" }); return { ok: true }; })); From bbb9a7d0a1c70a4dedd5116ff7f01d4469099995 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 10 Oct 2026 00:08:57 +0200 Subject: [PATCH 15/18] test: fix activity fixture typing --- apps/backend/src/lib/data/activities.service.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/lib/data/activities.service.test.ts b/apps/backend/src/lib/data/activities.service.test.ts index dc299d82..b7fdca30 100644 --- a/apps/backend/src/lib/data/activities.service.test.ts +++ b/apps/backend/src/lib/data/activities.service.test.ts @@ -36,7 +36,14 @@ function createPocketBaseFixture() { async create(payload: Record) { calls.push({ collection: name, method: "create" }); const now = new Date().toISOString(); - const row = { ...payload, id: `activity-${nextId++}`, created: now, createdAt: now } as Row; + const row = { + ...payload, + id: `activity-${nextId++}`, + owner: String(payload.owner ?? ""), + occurredAt: String(payload.occurredAt ?? now), + created: now, + createdAt: now, + } as Row; if (raceIdempotencyOnce && typeof payload.idempotencyKey === "string") { raceIdempotencyOnce = false; rows.push(row); From df1c055205a65d22bb4be84c42c15751be159c04 Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 10 Oct 2026 00:09:40 +0200 Subject: [PATCH 16/18] Keep activity target validation lint clean --- apps/backend/src/lib/data/activities.ts | 10 +++++++++- apps/web/src/lib/activityUtils.ts | 10 +++++++++- packages/integrationskit/activities.ts | 10 +++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/lib/data/activities.ts b/apps/backend/src/lib/data/activities.ts index a4c3dd70..b78b1e3d 100644 --- a/apps/backend/src/lib/data/activities.ts +++ b/apps/backend/src/lib/data/activities.ts @@ -345,7 +345,7 @@ function enforcePublishRateLimit(userId: string) { } function isSafeInternalTarget(target: string) { - if (!target.startsWith("/") || target.startsWith("//") || target.includes("\\") || /[\u0000-\u001f\u007f]/.test(target)) return false; + if (!target.startsWith("/") || target.startsWith("//") || target.includes("\\") || hasControlCharacters(target)) return false; try { const parsed = new URL(target, "https://dashwise.invalid"); return parsed.origin === "https://dashwise.invalid" && parsed.pathname.startsWith("/"); @@ -354,6 +354,14 @@ function isSafeInternalTarget(target: string) { } } +function hasControlCharacters(value: string) { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + function parseIntegerQuery(value: unknown, defaultValue: number, max: number) { if (value === undefined || value === "") return defaultValue; const parsed = Number(value); diff --git a/apps/web/src/lib/activityUtils.ts b/apps/web/src/lib/activityUtils.ts index f4dde471..80bd2181 100644 --- a/apps/web/src/lib/activityUtils.ts +++ b/apps/web/src/lib/activityUtils.ts @@ -1,5 +1,5 @@ export function safeActivityTarget(target?: string) { - if (!target || !target.startsWith("/") || target.startsWith("//") || target.includes("\\") || /[\u0000-\u001f\u007f]/.test(target)) return null; + if (!target || !target.startsWith("/") || target.startsWith("//") || target.includes("\\") || hasControlCharacters(target)) return null; try { const parsed = new URL(target, "https://dashwise.invalid"); return parsed.origin === "https://dashwise.invalid" ? `${parsed.pathname}${parsed.search}${parsed.hash}` : null; @@ -8,6 +8,14 @@ export function safeActivityTarget(target?: string) { } } +function hasControlCharacters(value: string) { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + export function formatActivityTime(value: string, now = Date.now()) { const date = new Date(value); if (Number.isNaN(date.getTime())) return ""; diff --git a/packages/integrationskit/activities.ts b/packages/integrationskit/activities.ts index f049a31e..565b1d37 100644 --- a/packages/integrationskit/activities.ts +++ b/packages/integrationskit/activities.ts @@ -228,7 +228,7 @@ function hasSensitiveMetadataKey(value: unknown): boolean { } function isSafeInternalTarget(target: string) { - if (!target.startsWith("/") || target.startsWith("//") || target.includes("\\") || /[\u0000-\u001f\u007f]/.test(target)) return false; + if (!target.startsWith("/") || target.startsWith("//") || target.includes("\\") || hasControlCharacters(target)) return false; try { const parsed = new URL(target, "https://dashwise.invalid"); return parsed.origin === "https://dashwise.invalid" && parsed.pathname.startsWith("/"); @@ -237,6 +237,14 @@ function isSafeInternalTarget(target: string) { } } +function hasControlCharacters(value: string) { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + function byteLength(value: string) { return new TextEncoder().encode(value).byteLength; } From ba3a8f8e89a40491c10677866279b16e24e5dd2e Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 10 Oct 2026 00:10:52 +0200 Subject: [PATCH 17/18] Generate activity API contracts --- apps/backend/openapi.yaml | 68 ++++- apps/web/public/openapi.json | 411 ++++++++++++++++++++++++++++++ apps/web/src/lib/api/index.ts | 4 +- apps/web/src/lib/api/sdk.gen.ts | 29 ++- apps/web/src/lib/api/types.gen.ts | 191 ++++++++++++++ packages/api-types/src/openapi.ts | 210 +++++++++++++++ 6 files changed, 905 insertions(+), 8 deletions(-) diff --git a/apps/backend/openapi.yaml b/apps/backend/openapi.yaml index 7c76aed8..701011b8 100644 --- a/apps/backend/openapi.yaml +++ b/apps/backend/openapi.yaml @@ -94,7 +94,11 @@ paths: schema: { type: string, format: date-time } responses: "200": - $ref: "#/components/responses/JsonOk" + description: Activity page + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityPage" "401": $ref: "#/components/responses/JsonUnauthorized" /activities/{id}: @@ -109,7 +113,15 @@ paths: schema: { type: string } responses: "200": - $ref: "#/components/responses/JsonOk" + description: Activity + content: + application/json: + schema: + type: object + required: [activity] + properties: + activity: + $ref: "#/components/schemas/Activity" "401": $ref: "#/components/responses/JsonUnauthorized" "404": @@ -310,7 +322,7 @@ paths: eventId: { type: string, maxLength: 200 } idempotencyKey: { type: string, maxLength: 256 } occurredAt: { type: string, format: date-time } - metadata: { type: object, maxProperties: 128 } + metadata: { type: object, maxProperties: 128, additionalProperties: true } action: type: object required: [label, target] @@ -319,12 +331,17 @@ paths: target: { type: string, maxLength: 2048, description: Safe internal application path only. } responses: "201": - $ref: "#/components/responses/JsonOk" + description: Activity published + content: + application/json: + schema: + $ref: "#/components/schemas/PublishActivityResult" "200": description: Duplicate idempotent submission content: application/json: - schema: { type: object, properties: { activity: { type: object }, duplicate: { type: boolean } } } + schema: + $ref: "#/components/schemas/PublishActivityResult" "400": $ref: "#/components/responses/JsonBadRequest" "401": @@ -1585,6 +1602,47 @@ components: schema: type: string schemas: + Activity: + type: object + required: [id, ownerId, source, type, title, severity, occurredAt, createdAt] + properties: + id: { type: string } + ownerId: { type: string } + source: { type: string, maxLength: 120 } + sourceId: { type: string, maxLength: 200 } + eventId: { type: string, maxLength: 200 } + type: { type: string, maxLength: 120 } + title: { type: string, maxLength: 200 } + description: { type: string, maxLength: 2000 } + severity: { type: string, enum: [info, success, warning, error] } + occurredAt: { type: string, format: date-time } + createdAt: { type: string, format: date-time } + metadata: { type: object, additionalProperties: true } + action: + type: object + required: [label, target] + properties: + label: { type: string, maxLength: 80 } + target: { type: string, maxLength: 2048, description: Safe internal application path only. } + ActivityPage: + type: object + required: [page, perPage, totalItems, totalPages, items] + properties: + page: { type: integer } + perPage: { type: integer } + totalItems: { type: integer } + totalPages: { type: integer } + items: + type: array + items: + $ref: "#/components/schemas/Activity" + PublishActivityResult: + type: object + required: [activity, duplicate] + properties: + activity: + $ref: "#/components/schemas/Activity" + duplicate: { type: boolean } GenericObject: type: object additionalProperties: true diff --git a/apps/web/public/openapi.json b/apps/web/public/openapi.json index 5e66d3eb..e79c608d 100644 --- a/apps/web/public/openapi.json +++ b/apps/web/public/openapi.json @@ -11,6 +11,9 @@ } ], "tags": [ + { + "name": "activities" + }, { "name": "app" }, @@ -122,6 +125,164 @@ } } }, + "/activities": { + "get": { + "tags": [ + "activities" + ], + "summary": "List the authenticated user's activities", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "perPage", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "source", + "in": "query", + "schema": { + "type": "string", + "maxLength": 120 + } + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string", + "maxLength": 120 + } + }, + { + "name": "severity", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "info", + "success", + "warning", + "error" + ] + } + }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "Activity page", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityPage" + } + } + } + }, + "401": { + "$ref": "#/components/responses/JsonUnauthorized" + } + } + } + }, + "/activities/{id}": { + "get": { + "tags": [ + "activities" + ], + "summary": "Get an activity owned by the authenticated user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Activity", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "activity" + ], + "properties": { + "activity": { + "$ref": "#/components/schemas/Activity" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/JsonUnauthorized" + }, + "404": { + "$ref": "#/components/responses/JsonNotFound" + } + } + }, + "delete": { + "tags": [ + "activities" + ], + "summary": "Delete an activity owned by the authenticated user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/JsonOk" + }, + "401": { + "$ref": "#/components/responses/JsonUnauthorized" + }, + "404": { + "$ref": "#/components/responses/JsonNotFound" + } + } + } + }, "/auth/callback": { "get": { "tags": [ @@ -368,6 +529,127 @@ } } }, + "/integrations/{integrationId}/activities": { + "post": { + "tags": [ + "activities" + ], + "summary": "Publish an activity for an integration owned by the authenticated user", + "parameters": [ + { + "name": "integrationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "type", + "title" + ], + "properties": { + "type": { + "type": "string", + "maxLength": 120 + }, + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string", + "maxLength": 2000 + }, + "severity": { + "type": "string", + "enum": [ + "info", + "success", + "warning", + "error" + ] + }, + "eventId": { + "type": "string", + "maxLength": 200 + }, + "idempotencyKey": { + "type": "string", + "maxLength": 256 + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "metadata": { + "type": "object", + "maxProperties": 128, + "additionalProperties": true + }, + "action": { + "type": "object", + "required": [ + "label", + "target" + ], + "properties": { + "label": { + "type": "string", + "maxLength": 80 + }, + "target": { + "type": "string", + "maxLength": 2048, + "description": "Safe internal application path only." + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Duplicate idempotent submission", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishActivityResult" + } + } + } + }, + "201": { + "description": "Activity published", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishActivityResult" + } + } + } + }, + "400": { + "$ref": "#/components/responses/JsonBadRequest" + }, + "401": { + "$ref": "#/components/responses/JsonUnauthorized" + }, + "404": { + "$ref": "#/components/responses/JsonNotFound" + } + } + } + }, "/integrations/test-endpoint": { "post": { "tags": [ @@ -2393,6 +2675,135 @@ } }, "schemas": { + "Activity": { + "type": "object", + "required": [ + "id", + "ownerId", + "source", + "type", + "title", + "severity", + "occurredAt", + "createdAt" + ], + "properties": { + "id": { + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "source": { + "type": "string", + "maxLength": 120 + }, + "sourceId": { + "type": "string", + "maxLength": 200 + }, + "eventId": { + "type": "string", + "maxLength": 200 + }, + "type": { + "type": "string", + "maxLength": 120 + }, + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string", + "maxLength": 2000 + }, + "severity": { + "type": "string", + "enum": [ + "info", + "success", + "warning", + "error" + ] + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "action": { + "type": "object", + "required": [ + "label", + "target" + ], + "properties": { + "label": { + "type": "string", + "maxLength": 80 + }, + "target": { + "type": "string", + "maxLength": 2048, + "description": "Safe internal application path only." + } + } + } + } + }, + "ActivityPage": { + "type": "object", + "required": [ + "page", + "perPage", + "totalItems", + "totalPages", + "items" + ], + "properties": { + "page": { + "type": "integer" + }, + "perPage": { + "type": "integer" + }, + "totalItems": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Activity" + } + } + } + }, + "PublishActivityResult": { + "type": "object", + "required": [ + "activity", + "duplicate" + ], + "properties": { + "activity": { + "$ref": "#/components/schemas/Activity" + }, + "duplicate": { + "type": "boolean" + } + } + }, "GenericObject": { "type": "object", "additionalProperties": true diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index 01227c73..d0e5c0cd 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -1,5 +1,5 @@ // DO NOT EDIT — generated by bun run generate // This file is auto-generated by @hey-api/openapi-ts -export { deleteAuthDeleteAccount, deleteIntegrationsById, deleteLinksDevUserLinks, deleteLinksItemsByLinkId, deleteMonitoringHostsById, deleteMonitoringSshHostsById, deleteMonitorsById, deleteNewsFeedRecordsById, deleteNotificationsForwarders, deleteNotificationsTopics, deleteNotificationsTopicTokens, getAppConfig, getAppInfo, getAuthCallback, getAuthSso, getGlanceables, getGlanceablesByIntegration, getIntegrations, getIntegrationsCaldavEvents, getIntegrationsConsumerData, getIntegrationsWidgetProperties, getJobsPullIcons, getJobsShortcuts, getLinksCollections, getLinksFolders, getLinksHome, getLinksHomeGroups, getLinksItems, getLinksMetadata, getLinksTags, getLocations, getMonitoringHosts, getMonitoringHostsById, getMonitoringHostsByIdHistory, getMonitoringHostsByIdStats, getMonitoringSshHosts, getMonitoringStatus, getMonitors, getMonitorsById, getNews, getNewsFeed, getNewsFeedMetadata, getNewsFeedRecordsById, getNewsFeedRefresh, getNewsFeeds, getNewsFeedsById, getNewsSubscriptions, getNewsSubscriptionsByIdJson, getNotifications, getNotificationsForwarders, getNotificationsTopics, getNotificationsTopicTokens, getPageConfig, getPageConfigUserPages, getSessionsCurrent, getShortcuts, getShortcutsFrequentlyUsed, getTestBookmarks, getWallpapers, getWeather, getWidgets, getWidgetsByIntegration, getWidgetsGlanceable, getWidgetsGlanceables, type Options, patchAuthUpdateUserProperty, patchSessionsCurrent, postAuthChangePassword, postAuthLogin, postAuthMfa, postAuthSignup, postAuthValidateAuth, postIntegrations, postIntegrationsConsumerData, postIntegrationsProxyAction, postIntegrationsTestEndpoint, postLinksCollections, postLinksFolders, postLinksHomeGroups, postLinksHomeGroupsCleanup, postLinksItems, postLinksReorder, postLinksTags, postMonitoringHosts, postMonitoringHostsByIdRefresh, postMonitoringSshHosts, postMonitoringStatus, postMonitors, postNewsFeedRecords, postNewsFeedRecordsById, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFixMissingTitles, postNotifications, postNotificationsByTopic, postNotificationsForwarders, postNotificationsForwardersTest, postNotificationsMarkAsRead, postNotificationsTest, postNotificationsTopics, postNotificationsTopicTokens, postPageConfigHome, postPageConfigIntegrationData, postPageConfigMigrateLegacy, postShortcutsApps, postShortcutsUsageStats, postWallpapers, putIntegrationsById, putLinksCollectionsByCollectionId, putLinksFoldersByFolderIdIcon, putLinksItemsByLinkId, putLinksTagsByTagId, putMonitoringHostsById, putMonitoringSshHostsById, putMonitorsById, putNotificationsForwarders, putNotificationsTopicTokens, putPageConfig, putShortcutsOnDemandByAppId } from './sdk.gen'; -export type { ClientOptions, DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountError, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponse, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponse, DeleteIntegrationsByIdResponses, DeleteLinksDevUserLinksData, DeleteLinksDevUserLinksResponse, DeleteLinksDevUserLinksResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponse, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponse, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponse, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponse, DeleteMonitorsByIdResponses, DeleteNewsFeedRecordsByIdData, DeleteNewsFeedRecordsByIdResponse, DeleteNewsFeedRecordsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponse, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponse, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponse, DeleteNotificationsTopicTokensResponses, Error, GenericObject, GetAppConfigData, GetAppConfigResponse, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponse, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponse, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponse, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponse, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponse, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponse, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponse, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponse, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponse, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsError, GetJobsPullIconsErrors, GetJobsPullIconsResponse, GetJobsPullIconsResponses, GetJobsShortcutsData, GetJobsShortcutsResponse, GetJobsShortcutsResponses, GetLinksCollectionsData, GetLinksCollectionsResponse, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponse, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponse, GetLinksHomeGroupsResponses, GetLinksHomeResponse, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponse, GetLinksItemsResponses, GetLinksMetadataData, GetLinksMetadataResponse, GetLinksMetadataResponses, GetLinksTagsData, GetLinksTagsResponse, GetLinksTagsResponses, GetLocationsData, GetLocationsResponse, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponse, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponse, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponse, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponse, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponse, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponse, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponse, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponse, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponse, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponse, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponse, GetNewsFeedRefreshResponses, GetNewsFeedResponse, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponse, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponse, GetNewsFeedsResponses, GetNewsResponse, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponse, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponse, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponse, GetNotificationsForwardersResponses, GetNotificationsResponse, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponse, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponse, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponse, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponse, GetPageConfigUserPagesResponses, GetSessionsCurrentData, GetSessionsCurrentError, GetSessionsCurrentErrors, GetSessionsCurrentResponse, GetSessionsCurrentResponses, GetShortcutsData, GetShortcutsFrequentlyUsedData, GetShortcutsFrequentlyUsedResponse, GetShortcutsFrequentlyUsedResponses, GetShortcutsResponse, GetShortcutsResponses, GetTestBookmarksData, GetTestBookmarksResponse, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponse, GetWallpapersResponses, GetWeatherData, GetWeatherResponse, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponse, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponse, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponse, GetWidgetsGlanceablesResponses, GetWidgetsResponse, GetWidgetsResponses, Id, JsonBody, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyError, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponse, PatchAuthUpdateUserPropertyResponses, PatchSessionsCurrentData, PatchSessionsCurrentError, PatchSessionsCurrentErrors, PatchSessionsCurrentResponse, PatchSessionsCurrentResponses, PostAuthChangePasswordData, PostAuthChangePasswordError, PostAuthChangePasswordErrors, PostAuthChangePasswordResponse, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginError, PostAuthLoginErrors, PostAuthLoginResponse, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaError, PostAuthMfaErrors, PostAuthMfaResponse, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupError, PostAuthSignupErrors, PostAuthSignupResponse, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthError, PostAuthValidateAuthErrors, PostAuthValidateAuthResponse, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponse, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponse, PostIntegrationsProxyActionResponses, PostIntegrationsResponse, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponse, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponse, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponse, PostLinksFoldersResponses, PostLinksHomeGroupsCleanupData, PostLinksHomeGroupsCleanupResponse, PostLinksHomeGroupsCleanupResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponse, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponse, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponse, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponse, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshError, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponse, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponse, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponse, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusError, PostMonitoringStatusErrors, PostMonitoringStatusResponse, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponse, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponse, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponse, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponse, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponse, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponse, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponse, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponse, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponse, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponse, PostNotificationsForwardersResponses, PostNotificationsForwardersTestData, PostNotificationsForwardersTestResponse, PostNotificationsForwardersTestResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponse, PostNotificationsMarkAsReadResponses, PostNotificationsResponse, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponse, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponse, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponse, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponse, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponse, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponse, PostPageConfigMigrateLegacyResponses, PostShortcutsAppsData, PostShortcutsAppsError, PostShortcutsAppsErrors, PostShortcutsAppsResponse, PostShortcutsAppsResponses, PostShortcutsUsageStatsData, PostShortcutsUsageStatsResponse, PostShortcutsUsageStatsResponses, PostWallpapersData, PostWallpapersResponse, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponse, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponse, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponse, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponse, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponse, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponse, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponse, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponse, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponse, PutNotificationsForwardersResponses, PutNotificationsTopicTokensData, PutNotificationsTopicTokensResponse, PutNotificationsTopicTokensResponses, PutPageConfigData, PutPageConfigResponse, PutPageConfigResponses, PutShortcutsOnDemandByAppIdData, PutShortcutsOnDemandByAppIdError, PutShortcutsOnDemandByAppIdErrors, PutShortcutsOnDemandByAppIdResponse, PutShortcutsOnDemandByAppIdResponses } from './types.gen'; +export { deleteActivitiesById, deleteAuthDeleteAccount, deleteIntegrationsById, deleteLinksDevUserLinks, deleteLinksItemsByLinkId, deleteMonitoringHostsById, deleteMonitoringSshHostsById, deleteMonitorsById, deleteNewsFeedRecordsById, deleteNotificationsForwarders, deleteNotificationsTopics, deleteNotificationsTopicTokens, getActivities, getActivitiesById, getAppConfig, getAppInfo, getAuthCallback, getAuthSso, getGlanceables, getGlanceablesByIntegration, getIntegrations, getIntegrationsCaldavEvents, getIntegrationsConsumerData, getIntegrationsWidgetProperties, getJobsPullIcons, getJobsShortcuts, getLinksCollections, getLinksFolders, getLinksHome, getLinksHomeGroups, getLinksItems, getLinksMetadata, getLinksTags, getLocations, getMonitoringHosts, getMonitoringHostsById, getMonitoringHostsByIdHistory, getMonitoringHostsByIdStats, getMonitoringSshHosts, getMonitoringStatus, getMonitors, getMonitorsById, getNews, getNewsFeed, getNewsFeedMetadata, getNewsFeedRecordsById, getNewsFeedRefresh, getNewsFeeds, getNewsFeedsById, getNewsSubscriptions, getNewsSubscriptionsByIdJson, getNotifications, getNotificationsForwarders, getNotificationsTopics, getNotificationsTopicTokens, getPageConfig, getPageConfigUserPages, getSessionsCurrent, getShortcuts, getShortcutsFrequentlyUsed, getTestBookmarks, getWallpapers, getWeather, getWidgets, getWidgetsByIntegration, getWidgetsGlanceable, getWidgetsGlanceables, type Options, patchAuthUpdateUserProperty, patchSessionsCurrent, postAuthChangePassword, postAuthLogin, postAuthMfa, postAuthSignup, postAuthValidateAuth, postIntegrations, postIntegrationsByIntegrationIdActivities, postIntegrationsConsumerData, postIntegrationsProxyAction, postIntegrationsTestEndpoint, postLinksCollections, postLinksFolders, postLinksHomeGroups, postLinksHomeGroupsCleanup, postLinksItems, postLinksReorder, postLinksTags, postMonitoringHosts, postMonitoringHostsByIdRefresh, postMonitoringSshHosts, postMonitoringStatus, postMonitors, postNewsFeedRecords, postNewsFeedRecordsById, postNewsFeedRefresh, postNewsFeedSubscribe, postNewsFeedUnsubscribe, postNewsFeedUpdate, postNewsFixMissingTitles, postNotifications, postNotificationsByTopic, postNotificationsForwarders, postNotificationsForwardersTest, postNotificationsMarkAsRead, postNotificationsTest, postNotificationsTopics, postNotificationsTopicTokens, postPageConfigHome, postPageConfigIntegrationData, postPageConfigMigrateLegacy, postShortcutsApps, postShortcutsUsageStats, postWallpapers, putIntegrationsById, putLinksCollectionsByCollectionId, putLinksFoldersByFolderIdIcon, putLinksItemsByLinkId, putLinksTagsByTagId, putMonitoringHostsById, putMonitoringSshHostsById, putMonitorsById, putNotificationsForwarders, putNotificationsTopicTokens, putPageConfig, putShortcutsOnDemandByAppId } from './sdk.gen'; +export type { Activity, ActivityPage, ClientOptions, DeleteActivitiesByIdData, DeleteActivitiesByIdError, DeleteActivitiesByIdErrors, DeleteActivitiesByIdResponse, DeleteActivitiesByIdResponses, DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountError, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponse, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponse, DeleteIntegrationsByIdResponses, DeleteLinksDevUserLinksData, DeleteLinksDevUserLinksResponse, DeleteLinksDevUserLinksResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponse, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponse, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponse, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponse, DeleteMonitorsByIdResponses, DeleteNewsFeedRecordsByIdData, DeleteNewsFeedRecordsByIdResponse, DeleteNewsFeedRecordsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponse, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponse, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponse, DeleteNotificationsTopicTokensResponses, Error, GenericObject, GetActivitiesByIdData, GetActivitiesByIdError, GetActivitiesByIdErrors, GetActivitiesByIdResponse, GetActivitiesByIdResponses, GetActivitiesData, GetActivitiesError, GetActivitiesErrors, GetActivitiesResponse, GetActivitiesResponses, GetAppConfigData, GetAppConfigResponse, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponse, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponse, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponse, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponse, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponse, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponse, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponse, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponse, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponse, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsError, GetJobsPullIconsErrors, GetJobsPullIconsResponse, GetJobsPullIconsResponses, GetJobsShortcutsData, GetJobsShortcutsResponse, GetJobsShortcutsResponses, GetLinksCollectionsData, GetLinksCollectionsResponse, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponse, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponse, GetLinksHomeGroupsResponses, GetLinksHomeResponse, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponse, GetLinksItemsResponses, GetLinksMetadataData, GetLinksMetadataResponse, GetLinksMetadataResponses, GetLinksTagsData, GetLinksTagsResponse, GetLinksTagsResponses, GetLocationsData, GetLocationsResponse, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponse, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponse, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponse, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponse, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponse, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponse, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponse, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponse, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponse, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponse, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponse, GetNewsFeedRefreshResponses, GetNewsFeedResponse, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponse, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponse, GetNewsFeedsResponses, GetNewsResponse, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponse, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponse, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponse, GetNotificationsForwardersResponses, GetNotificationsResponse, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponse, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponse, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponse, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponse, GetPageConfigUserPagesResponses, GetSessionsCurrentData, GetSessionsCurrentError, GetSessionsCurrentErrors, GetSessionsCurrentResponse, GetSessionsCurrentResponses, GetShortcutsData, GetShortcutsFrequentlyUsedData, GetShortcutsFrequentlyUsedResponse, GetShortcutsFrequentlyUsedResponses, GetShortcutsResponse, GetShortcutsResponses, GetTestBookmarksData, GetTestBookmarksResponse, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponse, GetWallpapersResponses, GetWeatherData, GetWeatherResponse, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponse, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponse, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponse, GetWidgetsGlanceablesResponses, GetWidgetsResponse, GetWidgetsResponses, Id, JsonBody, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyError, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponse, PatchAuthUpdateUserPropertyResponses, PatchSessionsCurrentData, PatchSessionsCurrentError, PatchSessionsCurrentErrors, PatchSessionsCurrentResponse, PatchSessionsCurrentResponses, PostAuthChangePasswordData, PostAuthChangePasswordError, PostAuthChangePasswordErrors, PostAuthChangePasswordResponse, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginError, PostAuthLoginErrors, PostAuthLoginResponse, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaError, PostAuthMfaErrors, PostAuthMfaResponse, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupError, PostAuthSignupErrors, PostAuthSignupResponse, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthError, PostAuthValidateAuthErrors, PostAuthValidateAuthResponse, PostAuthValidateAuthResponses, PostIntegrationsByIntegrationIdActivitiesData, PostIntegrationsByIntegrationIdActivitiesError, PostIntegrationsByIntegrationIdActivitiesErrors, PostIntegrationsByIntegrationIdActivitiesResponse, PostIntegrationsByIntegrationIdActivitiesResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponse, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponse, PostIntegrationsProxyActionResponses, PostIntegrationsResponse, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponse, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponse, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponse, PostLinksFoldersResponses, PostLinksHomeGroupsCleanupData, PostLinksHomeGroupsCleanupResponse, PostLinksHomeGroupsCleanupResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponse, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponse, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponse, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponse, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshError, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponse, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponse, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponse, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusError, PostMonitoringStatusErrors, PostMonitoringStatusResponse, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponse, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponse, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponse, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponse, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponse, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponse, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponse, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponse, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponse, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponse, PostNotificationsForwardersResponses, PostNotificationsForwardersTestData, PostNotificationsForwardersTestResponse, PostNotificationsForwardersTestResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponse, PostNotificationsMarkAsReadResponses, PostNotificationsResponse, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponse, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponse, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponse, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponse, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponse, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponse, PostPageConfigMigrateLegacyResponses, PostShortcutsAppsData, PostShortcutsAppsError, PostShortcutsAppsErrors, PostShortcutsAppsResponse, PostShortcutsAppsResponses, PostShortcutsUsageStatsData, PostShortcutsUsageStatsResponse, PostShortcutsUsageStatsResponses, PostWallpapersData, PostWallpapersResponse, PostWallpapersResponses, PublishActivityResult, PutIntegrationsByIdData, PutIntegrationsByIdResponse, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponse, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponse, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponse, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponse, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponse, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponse, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponse, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponse, PutNotificationsForwardersResponses, PutNotificationsTopicTokensData, PutNotificationsTopicTokensResponse, PutNotificationsTopicTokensResponses, PutPageConfigData, PutPageConfigResponse, PutPageConfigResponses, PutShortcutsOnDemandByAppIdData, PutShortcutsOnDemandByAppIdError, PutShortcutsOnDemandByAppIdErrors, PutShortcutsOnDemandByAppIdResponse, PutShortcutsOnDemandByAppIdResponses } from './types.gen'; diff --git a/apps/web/src/lib/api/sdk.gen.ts b/apps/web/src/lib/api/sdk.gen.ts index 5bf405fd..2436700e 100644 --- a/apps/web/src/lib/api/sdk.gen.ts +++ b/apps/web/src/lib/api/sdk.gen.ts @@ -3,7 +3,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponses, DeleteLinksDevUserLinksData, DeleteLinksDevUserLinksResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponses, DeleteNewsFeedRecordsByIdData, DeleteNewsFeedRecordsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponses, GetAppConfigData, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsErrors, GetJobsPullIconsResponses, GetJobsShortcutsData, GetJobsShortcutsResponses, GetLinksCollectionsData, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponses, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponses, GetLinksMetadataData, GetLinksMetadataResponses, GetLinksTagsData, GetLinksTagsResponses, GetLocationsData, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponses, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponses, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponses, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponses, GetSessionsCurrentData, GetSessionsCurrentErrors, GetSessionsCurrentResponses, GetShortcutsData, GetShortcutsFrequentlyUsedData, GetShortcutsFrequentlyUsedResponses, GetShortcutsResponses, GetTestBookmarksData, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponses, GetWeatherData, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponses, GetWidgetsResponses, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponses, PatchSessionsCurrentData, PatchSessionsCurrentErrors, PatchSessionsCurrentResponses, PostAuthChangePasswordData, PostAuthChangePasswordErrors, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginErrors, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaErrors, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupErrors, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthErrors, PostAuthValidateAuthResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponses, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponses, PostLinksHomeGroupsCleanupData, PostLinksHomeGroupsCleanupResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusErrors, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponses, PostNotificationsForwardersTestData, PostNotificationsForwardersTestResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponses, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponses, PostShortcutsAppsData, PostShortcutsAppsErrors, PostShortcutsAppsResponses, PostShortcutsUsageStatsData, PostShortcutsUsageStatsResponses, PostWallpapersData, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponses, PutNotificationsTopicTokensData, PutNotificationsTopicTokensResponses, PutPageConfigData, PutPageConfigResponses, PutShortcutsOnDemandByAppIdData, PutShortcutsOnDemandByAppIdErrors, PutShortcutsOnDemandByAppIdResponses } from './types.gen'; +import type { DeleteActivitiesByIdData, DeleteActivitiesByIdErrors, DeleteActivitiesByIdResponses, DeleteAuthDeleteAccountData, DeleteAuthDeleteAccountErrors, DeleteAuthDeleteAccountResponses, DeleteIntegrationsByIdData, DeleteIntegrationsByIdResponses, DeleteLinksDevUserLinksData, DeleteLinksDevUserLinksResponses, DeleteLinksItemsByLinkIdData, DeleteLinksItemsByLinkIdResponses, DeleteMonitoringHostsByIdData, DeleteMonitoringHostsByIdResponses, DeleteMonitoringSshHostsByIdData, DeleteMonitoringSshHostsByIdResponses, DeleteMonitorsByIdData, DeleteMonitorsByIdResponses, DeleteNewsFeedRecordsByIdData, DeleteNewsFeedRecordsByIdResponses, DeleteNotificationsForwardersData, DeleteNotificationsForwardersResponses, DeleteNotificationsTopicsData, DeleteNotificationsTopicsResponses, DeleteNotificationsTopicTokensData, DeleteNotificationsTopicTokensResponses, GetActivitiesByIdData, GetActivitiesByIdErrors, GetActivitiesByIdResponses, GetActivitiesData, GetActivitiesErrors, GetActivitiesResponses, GetAppConfigData, GetAppConfigResponses, GetAppInfoData, GetAppInfoResponses, GetAuthCallbackData, GetAuthCallbackResponses, GetAuthSsoData, GetAuthSsoResponses, GetGlanceablesByIntegrationData, GetGlanceablesByIntegrationResponses, GetGlanceablesData, GetGlanceablesResponses, GetIntegrationsCaldavEventsData, GetIntegrationsCaldavEventsResponses, GetIntegrationsConsumerDataData, GetIntegrationsConsumerDataResponses, GetIntegrationsData, GetIntegrationsResponses, GetIntegrationsWidgetPropertiesData, GetIntegrationsWidgetPropertiesResponses, GetJobsPullIconsData, GetJobsPullIconsErrors, GetJobsPullIconsResponses, GetJobsShortcutsData, GetJobsShortcutsResponses, GetLinksCollectionsData, GetLinksCollectionsResponses, GetLinksFoldersData, GetLinksFoldersResponses, GetLinksHomeData, GetLinksHomeGroupsData, GetLinksHomeGroupsResponses, GetLinksHomeResponses, GetLinksItemsData, GetLinksItemsResponses, GetLinksMetadataData, GetLinksMetadataResponses, GetLinksTagsData, GetLinksTagsResponses, GetLocationsData, GetLocationsResponses, GetMonitoringHostsByIdData, GetMonitoringHostsByIdHistoryData, GetMonitoringHostsByIdHistoryResponses, GetMonitoringHostsByIdResponses, GetMonitoringHostsByIdStatsData, GetMonitoringHostsByIdStatsResponses, GetMonitoringHostsData, GetMonitoringHostsResponses, GetMonitoringSshHostsData, GetMonitoringSshHostsResponses, GetMonitoringStatusData, GetMonitoringStatusResponses, GetMonitorsByIdData, GetMonitorsByIdResponses, GetMonitorsData, GetMonitorsResponses, GetNewsData, GetNewsFeedData, GetNewsFeedMetadataData, GetNewsFeedMetadataResponses, GetNewsFeedRecordsByIdData, GetNewsFeedRecordsByIdResponses, GetNewsFeedRefreshData, GetNewsFeedRefreshResponses, GetNewsFeedResponses, GetNewsFeedsByIdData, GetNewsFeedsByIdResponses, GetNewsFeedsData, GetNewsFeedsResponses, GetNewsResponses, GetNewsSubscriptionsByIdJsonData, GetNewsSubscriptionsByIdJsonResponses, GetNewsSubscriptionsData, GetNewsSubscriptionsResponses, GetNotificationsData, GetNotificationsForwardersData, GetNotificationsForwardersResponses, GetNotificationsResponses, GetNotificationsTopicsData, GetNotificationsTopicsResponses, GetNotificationsTopicTokensData, GetNotificationsTopicTokensResponses, GetPageConfigData, GetPageConfigResponses, GetPageConfigUserPagesData, GetPageConfigUserPagesResponses, GetSessionsCurrentData, GetSessionsCurrentErrors, GetSessionsCurrentResponses, GetShortcutsData, GetShortcutsFrequentlyUsedData, GetShortcutsFrequentlyUsedResponses, GetShortcutsResponses, GetTestBookmarksData, GetTestBookmarksResponses, GetWallpapersData, GetWallpapersResponses, GetWeatherData, GetWeatherResponses, GetWidgetsByIntegrationData, GetWidgetsByIntegrationResponses, GetWidgetsData, GetWidgetsGlanceableData, GetWidgetsGlanceableResponses, GetWidgetsGlanceablesData, GetWidgetsGlanceablesResponses, GetWidgetsResponses, PatchAuthUpdateUserPropertyData, PatchAuthUpdateUserPropertyErrors, PatchAuthUpdateUserPropertyResponses, PatchSessionsCurrentData, PatchSessionsCurrentErrors, PatchSessionsCurrentResponses, PostAuthChangePasswordData, PostAuthChangePasswordErrors, PostAuthChangePasswordResponses, PostAuthLoginData, PostAuthLoginErrors, PostAuthLoginResponses, PostAuthMfaData, PostAuthMfaErrors, PostAuthMfaResponses, PostAuthSignupData, PostAuthSignupErrors, PostAuthSignupResponses, PostAuthValidateAuthData, PostAuthValidateAuthErrors, PostAuthValidateAuthResponses, PostIntegrationsByIntegrationIdActivitiesData, PostIntegrationsByIntegrationIdActivitiesErrors, PostIntegrationsByIntegrationIdActivitiesResponses, PostIntegrationsConsumerDataData, PostIntegrationsConsumerDataResponses, PostIntegrationsData, PostIntegrationsProxyActionData, PostIntegrationsProxyActionResponses, PostIntegrationsResponses, PostIntegrationsTestEndpointData, PostIntegrationsTestEndpointResponses, PostLinksCollectionsData, PostLinksCollectionsResponses, PostLinksFoldersData, PostLinksFoldersResponses, PostLinksHomeGroupsCleanupData, PostLinksHomeGroupsCleanupResponses, PostLinksHomeGroupsData, PostLinksHomeGroupsResponses, PostLinksItemsData, PostLinksItemsResponses, PostLinksReorderData, PostLinksReorderResponses, PostLinksTagsData, PostLinksTagsResponses, PostMonitoringHostsByIdRefreshData, PostMonitoringHostsByIdRefreshErrors, PostMonitoringHostsByIdRefreshResponses, PostMonitoringHostsData, PostMonitoringHostsResponses, PostMonitoringSshHostsData, PostMonitoringSshHostsResponses, PostMonitoringStatusData, PostMonitoringStatusErrors, PostMonitoringStatusResponses, PostMonitorsData, PostMonitorsResponses, PostNewsFeedRecordsByIdData, PostNewsFeedRecordsByIdResponses, PostNewsFeedRecordsData, PostNewsFeedRecordsResponses, PostNewsFeedRefreshData, PostNewsFeedRefreshResponses, PostNewsFeedSubscribeData, PostNewsFeedSubscribeResponses, PostNewsFeedUnsubscribeData, PostNewsFeedUnsubscribeResponses, PostNewsFeedUpdateData, PostNewsFeedUpdateResponses, PostNewsFixMissingTitlesData, PostNewsFixMissingTitlesResponses, PostNotificationsByTopicData, PostNotificationsByTopicResponses, PostNotificationsData, PostNotificationsForwardersData, PostNotificationsForwardersResponses, PostNotificationsForwardersTestData, PostNotificationsForwardersTestResponses, PostNotificationsMarkAsReadData, PostNotificationsMarkAsReadResponses, PostNotificationsResponses, PostNotificationsTestData, PostNotificationsTestResponses, PostNotificationsTopicsData, PostNotificationsTopicsResponses, PostNotificationsTopicTokensData, PostNotificationsTopicTokensResponses, PostPageConfigHomeData, PostPageConfigHomeResponses, PostPageConfigIntegrationDataData, PostPageConfigIntegrationDataResponses, PostPageConfigMigrateLegacyData, PostPageConfigMigrateLegacyResponses, PostShortcutsAppsData, PostShortcutsAppsErrors, PostShortcutsAppsResponses, PostShortcutsUsageStatsData, PostShortcutsUsageStatsResponses, PostWallpapersData, PostWallpapersResponses, PutIntegrationsByIdData, PutIntegrationsByIdResponses, PutLinksCollectionsByCollectionIdData, PutLinksCollectionsByCollectionIdResponses, PutLinksFoldersByFolderIdIconData, PutLinksFoldersByFolderIdIconResponses, PutLinksItemsByLinkIdData, PutLinksItemsByLinkIdResponses, PutLinksTagsByTagIdData, PutLinksTagsByTagIdResponses, PutMonitoringHostsByIdData, PutMonitoringHostsByIdResponses, PutMonitoringSshHostsByIdData, PutMonitoringSshHostsByIdResponses, PutMonitorsByIdData, PutMonitorsByIdResponses, PutNotificationsForwardersData, PutNotificationsForwardersResponses, PutNotificationsTopicTokensData, PutNotificationsTopicTokensResponses, PutPageConfigData, PutPageConfigResponses, PutShortcutsOnDemandByAppIdData, PutShortcutsOnDemandByAppIdErrors, PutShortcutsOnDemandByAppIdResponses } from './types.gen'; export type Options = Options2 & { /** @@ -29,6 +29,21 @@ export const getAppConfig = (options?: Opt */ export const getAppInfo = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/appInfo', ...options }); +/** + * List the authenticated user's activities + */ +export const getActivities = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/activities', ...options }); + +/** + * Delete an activity owned by the authenticated user + */ +export const deleteActivitiesById = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/activities/{id}', ...options }); + +/** + * Get an activity owned by the authenticated user + */ +export const getActivitiesById = (options: Options): RequestResult => (options.client ?? client).get({ url: '/activities/{id}', ...options }); + /** * Auth callback */ @@ -157,6 +172,18 @@ export const postIntegrations = (options?: } }); +/** + * Publish an activity for an integration owned by the authenticated user + */ +export const postIntegrationsByIntegrationIdActivities = (options: Options): RequestResult => (options.client ?? client).post({ + url: '/integrations/{integrationId}/activities', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * Test integration endpoint */ diff --git a/apps/web/src/lib/api/types.gen.ts b/apps/web/src/lib/api/types.gen.ts index 13d17b4e..c6c40a23 100644 --- a/apps/web/src/lib/api/types.gen.ts +++ b/apps/web/src/lib/api/types.gen.ts @@ -5,6 +5,43 @@ export type ClientOptions = { baseUrl: `${string}://${string}/api/v1` | (string & {}); }; +export type Activity = { + id: string; + ownerId: string; + source: string; + sourceId?: string; + eventId?: string; + type: string; + title: string; + description?: string; + severity: 'info' | 'success' | 'warning' | 'error'; + occurredAt: string; + createdAt: string; + metadata?: { + [key: string]: unknown; + }; + action?: { + label: string; + /** + * Safe internal application path only. + */ + target: string; + }; +}; + +export type ActivityPage = { + page: number; + perPage: number; + totalItems: number; + totalPages: number; + items: Array; +}; + +export type PublishActivityResult = { + activity: Activity; + duplicate: boolean; +}; + export type GenericObject = { [key: string]: unknown; }; @@ -55,6 +92,103 @@ export type GetAppInfoResponses = { export type GetAppInfoResponse = GetAppInfoResponses[keyof GetAppInfoResponses]; +export type GetActivitiesData = { + body?: never; + path?: never; + query?: { + page?: number; + perPage?: number; + source?: string; + type?: string; + severity?: 'info' | 'success' | 'warning' | 'error'; + from?: string; + to?: string; + }; + url: '/activities'; +}; + +export type GetActivitiesErrors = { + /** + * Unauthorized + */ + 401: Error; +}; + +export type GetActivitiesError = GetActivitiesErrors[keyof GetActivitiesErrors]; + +export type GetActivitiesResponses = { + /** + * Activity page + */ + 200: ActivityPage; +}; + +export type GetActivitiesResponse = GetActivitiesResponses[keyof GetActivitiesResponses]; + +export type DeleteActivitiesByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/activities/{id}'; +}; + +export type DeleteActivitiesByIdErrors = { + /** + * Unauthorized + */ + 401: Error; + /** + * Not Found + */ + 404: Error; +}; + +export type DeleteActivitiesByIdError = DeleteActivitiesByIdErrors[keyof DeleteActivitiesByIdErrors]; + +export type DeleteActivitiesByIdResponses = { + /** + * OK + */ + 200: GenericObject; +}; + +export type DeleteActivitiesByIdResponse = DeleteActivitiesByIdResponses[keyof DeleteActivitiesByIdResponses]; + +export type GetActivitiesByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/activities/{id}'; +}; + +export type GetActivitiesByIdErrors = { + /** + * Unauthorized + */ + 401: Error; + /** + * Not Found + */ + 404: Error; +}; + +export type GetActivitiesByIdError = GetActivitiesByIdErrors[keyof GetActivitiesByIdErrors]; + +export type GetActivitiesByIdResponses = { + /** + * Activity + */ + 200: { + activity: Activity; + }; +}; + +export type GetActivitiesByIdResponse = GetActivitiesByIdResponses[keyof GetActivitiesByIdResponses]; + export type GetAuthCallbackData = { body?: never; path?: never; @@ -351,6 +485,63 @@ export type PostIntegrationsResponses = { export type PostIntegrationsResponse = PostIntegrationsResponses[keyof PostIntegrationsResponses]; +export type PostIntegrationsByIntegrationIdActivitiesData = { + body: { + type: string; + title: string; + description?: string; + severity?: 'info' | 'success' | 'warning' | 'error'; + eventId?: string; + idempotencyKey?: string; + occurredAt?: string; + metadata?: { + [key: string]: unknown; + }; + action?: { + label: string; + /** + * Safe internal application path only. + */ + target: string; + }; + }; + path: { + integrationId: string; + }; + query?: never; + url: '/integrations/{integrationId}/activities'; +}; + +export type PostIntegrationsByIntegrationIdActivitiesErrors = { + /** + * Bad Request + */ + 400: Error; + /** + * Unauthorized + */ + 401: Error; + /** + * Not Found + */ + 404: Error; +}; + +export type PostIntegrationsByIntegrationIdActivitiesError = PostIntegrationsByIntegrationIdActivitiesErrors[keyof PostIntegrationsByIntegrationIdActivitiesErrors]; + +export type PostIntegrationsByIntegrationIdActivitiesResponses = { + /** + * Duplicate idempotent submission + */ + 200: PublishActivityResult; + /** + * Activity published + */ + 201: PublishActivityResult; +}; + +export type PostIntegrationsByIntegrationIdActivitiesResponse = PostIntegrationsByIntegrationIdActivitiesResponses[keyof PostIntegrationsByIntegrationIdActivitiesResponses]; + export type PostIntegrationsTestEndpointData = { body?: JsonBody; path?: never; diff --git a/packages/api-types/src/openapi.ts b/packages/api-types/src/openapi.ts index f7c9585f..097e29fe 100644 --- a/packages/api-types/src/openapi.ts +++ b/packages/api-types/src/openapi.ts @@ -83,6 +83,109 @@ export interface paths { patch?: never; trace?: never; }; + "/activities": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the authenticated user's activities */ + get: { + parameters: { + query?: { + page?: number; + perPage?: number; + source?: string; + type?: string; + severity?: "info" | "success" | "warning" | "error"; + from?: string; + to?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Activity page */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ActivityPage"]; + }; + }; + 401: components["responses"]["JsonUnauthorized"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/activities/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get an activity owned by the authenticated user */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Activity */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + activity: components["schemas"]["Activity"]; + }; + }; + }; + 401: components["responses"]["JsonUnauthorized"]; + 404: components["responses"]["JsonNotFound"]; + }; + }; + put?: never; + post?: never; + /** Delete an activity owned by the authenticated user */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["JsonOk"]; + 401: components["responses"]["JsonUnauthorized"]; + 404: components["responses"]["JsonNotFound"]; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/auth/callback": { parameters: { query?: never; @@ -442,6 +545,78 @@ export interface paths { patch?: never; trace?: never; }; + "/integrations/{integrationId}/activities": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Publish an activity for an integration owned by the authenticated user */ + post: { + parameters: { + query?: never; + header?: never; + path: { + integrationId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + type: string; + title: string; + description?: string; + /** @enum {string} */ + severity?: "info" | "success" | "warning" | "error"; + eventId?: string; + idempotencyKey?: string; + /** Format: date-time */ + occurredAt?: string; + metadata?: { + [key: string]: unknown; + }; + action?: { + label: string; + /** @description Safe internal application path only. */ + target: string; + }; + }; + }; + }; + responses: { + /** @description Duplicate idempotent submission */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublishActivityResult"]; + }; + }; + /** @description Activity published */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublishActivityResult"]; + }; + }; + 400: components["responses"]["JsonBadRequest"]; + 401: components["responses"]["JsonUnauthorized"]; + 404: components["responses"]["JsonNotFound"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/integrations/test-endpoint": { parameters: { query?: never; @@ -3029,6 +3204,41 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + Activity: { + id: string; + ownerId: string; + source: string; + sourceId?: string; + eventId?: string; + type: string; + title: string; + description?: string; + /** @enum {string} */ + severity: "info" | "success" | "warning" | "error"; + /** Format: date-time */ + occurredAt: string; + /** Format: date-time */ + createdAt: string; + metadata?: { + [key: string]: unknown; + }; + action?: { + label: string; + /** @description Safe internal application path only. */ + target: string; + }; + }; + ActivityPage: { + page: number; + perPage: number; + totalItems: number; + totalPages: number; + items: components["schemas"]["Activity"][]; + }; + PublishActivityResult: { + activity: components["schemas"]["Activity"]; + duplicate: boolean; + }; GenericObject: { [key: string]: unknown; }; From fcb24a2d5aa1683f532ce17164d0bda003d2261b Mon Sep 17 00:00:00 2001 From: Andreas Molnar Date: Sat, 10 Oct 2026 00:13:00 +0200 Subject: [PATCH 18/18] Base activity retention on server creation time --- .../src/lib/data/activities.service.test.ts | 15 ++++++++++----- apps/backend/src/lib/data/activities.ts | 6 +++--- .../migrations/1785000009_created_activities.js | 1 + test/activity-collection-migration.test.ts | 1 + 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/backend/src/lib/data/activities.service.test.ts b/apps/backend/src/lib/data/activities.service.test.ts index b7fdca30..ad926c3d 100644 --- a/apps/backend/src/lib/data/activities.service.test.ts +++ b/apps/backend/src/lib/data/activities.service.test.ts @@ -80,13 +80,16 @@ function createPocketBaseFixture() { if (severityMatch && item.severity !== severityMatch[1]) return false; const before = /occurredAt < "([^"]+)"/.exec(options.filter); if (before && !(item.occurredAt < before[1]!)) return false; + const createdBefore = /createdAt < "([^"]+)"/.exec(options.filter); + if (createdBefore && !(String(item.createdAt || "") < createdBefore[1]!)) return false; const from = /occurredAt >= "([^"]+)"/.exec(options.filter); if (from && !(item.occurredAt >= from[1]!)) return false; const to = /occurredAt <= "([^"]+)"/.exec(options.filter); if (to && !(item.occurredAt <= to[1]!)) return false; return true; }); - if (options.sort === "occurredAt") matching = matching.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt)); + if (options.sort === "createdAt") matching = matching.sort((a, b) => String(a.createdAt || "").localeCompare(String(b.createdAt || ""))); + else if (options.sort === "occurredAt") matching = matching.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt)); else matching = matching.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); const totalItems = matching.length; const start = (page - 1) * perPage; @@ -96,7 +99,7 @@ function createPocketBaseFixture() { perPage, totalItems, totalPages: Math.ceil(totalItems / perPage), - items: options.fields ? items.map(({ id, owner, occurredAt }) => ({ id, owner, occurredAt })) : items, + items: options.fields ? items.map(({ id, owner, occurredAt, createdAt }) => ({ id, owner, occurredAt, createdAt })) : items, }; }, async delete(id: string) { @@ -200,12 +203,14 @@ describe("activity persistence service", () => { test("retention deletes only expired records, broadcasts affected owners, and keeps recent events", async () => { const expiry = Date.now() - 5 * 24 * 60 * 60 * 1_000; - await publishActivity("owner-a", "monitoring", { - type: "old.event", title: "Old", occurredAt: new Date(expiry).toISOString(), + const expired = await publishActivity("owner-a", "monitoring", { + type: "expired.event", title: "Expired", occurredAt: new Date().toISOString(), }); const recent = await publishActivity("owner-b", "monitoring", { - type: "new.event", title: "Recent", occurredAt: new Date().toISOString(), + type: "late.event", title: "Recently stored late event", occurredAt: new Date(expiry).toISOString(), }); + const expiredRow = fixture.rows.find((row) => row.id === expired.activity.id)!; + expiredRow.createdAt = new Date(expiry).toISOString(); let ownerAEvents = 0; let ownerBEvents = 0; unsubscribe.push(subscribeActivity("owner-a", async () => { ownerAEvents += 1; })); diff --git a/apps/backend/src/lib/data/activities.ts b/apps/backend/src/lib/data/activities.ts index b78b1e3d..cb06b18b 100644 --- a/apps/backend/src/lib/data/activities.ts +++ b/apps/backend/src/lib/data/activities.ts @@ -245,9 +245,9 @@ export async function deleteExpiredActivities(retentionDays: number) { let deleted = 0; while (true) { const expired = await pb.collection("activities").getList(1, 500, { - filter: `occurredAt < ${filterString(cutoff)}`, - fields: "id,owner", - sort: "occurredAt", + filter: `createdAt < ${filterString(cutoff)}`, + fields: "id,owner,createdAt", + sort: "createdAt", }); if (expired.items.length === 0) break; for (const record of expired.items) affectedOwners.add(record.owner); diff --git a/pocketbase/migrations/1785000009_created_activities.js b/pocketbase/migrations/1785000009_created_activities.js index 5cbdf177..da14fe00 100644 --- a/pocketbase/migrations/1785000009_created_activities.js +++ b/pocketbase/migrations/1785000009_created_activities.js @@ -188,6 +188,7 @@ migrate((app) => { id: "pbc_activity001", indexes: [ "CREATE INDEX idx_activities_owner_occurred_at ON activities (owner, occurredAt DESC)", + "CREATE INDEX idx_activities_created_at ON activities (createdAt)", "CREATE UNIQUE INDEX idx_activities_owner_source_instance_idempotency ON activities (owner, source, sourceId, idempotencyKey) WHERE idempotencyKey != ''", ], listRule: null, diff --git a/test/activity-collection-migration.test.ts b/test/activity-collection-migration.test.ts index 19e0d23d..59c3f8eb 100644 --- a/test/activity-collection-migration.test.ts +++ b/test/activity-collection-migration.test.ts @@ -43,6 +43,7 @@ describe("activities PocketBase migration", () => { test("indexes owner chronology and idempotency per source instance", () => { expect(migration).toContain("(owner, occurredAt DESC)"); + expect(migration).toContain("idx_activities_created_at ON activities (createdAt)"); expect(migration).toContain("UNIQUE INDEX idx_activities_owner_source_instance_idempotency"); expect(migration).toContain("(owner, source, sourceId, idempotencyKey)"); expect(migration).toContain("WHERE idempotencyKey != ''");