diff --git a/apps/backend/openapi.yaml b/apps/backend/openapi.yaml index fb317510..701011b8 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,83 @@ 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": + 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: @@ -219,6 +297,57 @@ 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, 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: + "201": + description: Activity published + content: + application/json: + schema: + $ref: "#/components/schemas/PublishActivityResult" + "200": + description: Duplicate idempotent submission + 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: @@ -1473,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/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/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; 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.service.test.ts b/apps/backend/src/lib/data/activities.service.test.ts new file mode 100644 index 00000000..ad926c3d --- /dev/null +++ b/apps/backend/src/lib/data/activities.service.test.ts @@ -0,0 +1,225 @@ +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; + let raceIdempotencyOnce = false; + 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++}`, + 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); + throw new Error("UNIQUE constraint failed: activities.idempotencyKey"); + } + 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 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 === "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; + const items = matching.slice(start, start + perPage); + return { + page, + perPage, + totalItems, + totalPages: Math.ceil(totalItems / perPage), + items: options.fields ? items.map(({ id, owner, occurredAt, createdAt }) => ({ id, owner, occurredAt, createdAt })) : 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, + simulateIdempotencyRace() { raceIdempotencyOnce = true; }, + }; +} + +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("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" }); + + 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; + const expired = await publishActivity("owner-a", "monitoring", { + type: "expired.event", title: "Expired", occurredAt: new Date().toISOString(), + }); + const recent = await publishActivity("owner-b", "monitoring", { + 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; })); + 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/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..cb06b18b --- /dev/null +++ b/apps/backend/src/lib/data/activities.ts @@ -0,0 +1,386 @@ +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 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"; + 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, normalizedSource, normalizedSourceId ?? "", idempotencyKey); + if (existing) return { activity: mapActivity(existing), duplicate: true }; + } + + const payload = { + owner: userId, + source: normalizedSource, + ...(normalizedSourceId ? { sourceId: normalizedSourceId } : {}), + ...(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, normalizedSource, normalizedSourceId ?? "", 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: `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); + 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("\\") || hasControlCharacters(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 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); + 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.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" }); + } + }); +}); diff --git a/apps/backend/src/routes/activities.route.ts b/apps/backend/src/routes/activities.route.ts new file mode 100644 index 00000000..21c59dab --- /dev/null +++ b/apps/backend/src/routes/activities.route.ts @@ -0,0 +1,71 @@ +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(); + +async function requireActivityOwner(token: string | null) { + const { userId } = await requireAuth({ token }); + if (!userId) throw new ApiActionError("Unauthorized", 401, { error: "Unauthorized" }); + 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()); + return listActivities(userId, query); +})); + +activitiesRoute.get("/api/v1/activities/:id", withJson(async (c) => { + const userId = await requireActivityOwner(readAuthToken(c)); + 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, requireActivityId(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 requireActivityOwner(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; 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/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..c2afc360 100644 --- a/apps/web/src/context/ActivityContext.tsx +++ b/apps/web/src/context/ActivityContext.tsx @@ -81,7 +81,12 @@ 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; + 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); 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..80bd2181 --- /dev/null +++ b/apps/web/src/lib/activityUtils.ts @@ -0,0 +1,27 @@ +export function safeActivityTarget(target?: string) { + 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; + } catch { + return null; + } +} + +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 ""; + 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/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/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: ( 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/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. 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; }; 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..565b1d37 --- /dev/null +++ b/packages/integrationskit/activities.ts @@ -0,0 +1,267 @@ +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("\\") || hasControlCharacters(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 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; +} + +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", 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"; 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 diff --git a/pocketbase/migrations/1785000009_created_activities.js b/pocketbase/migrations/1785000009_created_activities.js new file mode 100644 index 00000000..da14fe00 --- /dev/null +++ b/pocketbase/migrations/1785000009_created_activities.js @@ -0,0 +1,206 @@ +/// +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 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, + 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..59c3f8eb --- /dev/null +++ b/test/activity-collection-migration.test.ts @@ -0,0 +1,51 @@ +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("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 != ''"); + }); +});