From 97e126fcd0892e54565ac7d33c5386b711f8e2fd Mon Sep 17 00:00:00 2001 From: abujalance Date: Thu, 13 Aug 2026 09:21:39 +0200 Subject: [PATCH 1/7] feat: notification methods on PlatformClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apps can already call the notification API — the iframe hands them the signed-in user's JWT — so this is client methods over endpoints that are already live, not new surface. Adds getNotifications, getUnreadNotificationCount, markNotificationsRead, markAllNotificationsRead, getNotificationSubscriptions and unsubscribeFromAutomation. The types come from the backend repo through a submodule rather than being copied into src/types like every other type here. Those copies are why base.ts has to alias ObjectId to string and hope it stays true; the backend's src/common/dto describes what the API actually sends and imports nothing from mongodb, so it compiles in a browser build unchanged. The submodule is sparse-checked-out to that one directory, so 37 files land on disk instead of the whole backend repo. Sparse config lives in the submodule's .git and does not survive a clone, which is why CI runs types:init after checkout. Moving the pin is `yarn types:update`, a command rather than a postinstall hook: an install that silently moved it would break builds with no commit explaining why. vite-plugin-dts and tsconfig both needed the vendored path adding. Without it the published .d.ts re-exports from ../../vendor/... and that resolves to nothing once installed — the build succeeds and consumers get broken types. Pinned to the backend's feature/notification-dtos branch until that merges. --- .changeset/notifications-client.md | 13 ++ .github/workflows/ci.yml | 7 + .github/workflows/release.yml | 7 + .gitmodules | 4 + package.json | 2 + scripts/update-types.sh | 37 ++++ .../platform-client.notifications.test.ts | 166 ++++++++++++++++++ src/core/platform-client.ts | 101 +++++++++++ src/index.ts | 1 + src/types/notifications.ts | 39 ++++ tsconfig.json | 6 +- vendor/backend-api | 1 + vite.config.mts | 6 +- 13 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 .changeset/notifications-client.md create mode 100644 .gitmodules create mode 100755 scripts/update-types.sh create mode 100644 src/core/platform-client.notifications.test.ts create mode 100644 src/types/notifications.ts create mode 160000 vendor/backend-api diff --git a/.changeset/notifications-client.md b/.changeset/notifications-client.md new file mode 100644 index 0000000..b9dfe0f --- /dev/null +++ b/.changeset/notifications-client.md @@ -0,0 +1,13 @@ +--- +'@thatopen/services': minor +--- + +Add notification methods to `PlatformClient`: `getNotifications`, +`getUnreadNotificationCount`, `markNotificationsRead`, +`markAllNotificationsRead`, `getNotificationSubscriptions` and +`unsubscribeFromAutomation`. All scoped to the signed-in user via the +bearer token an app already has. + +The notification types are re-exported from the backend repo rather than +copied into `src/types`, so the contract cannot drift from what the API +sends. Run `yarn types:update` to move the pin. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9973b3..e1b0008 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,12 +9,19 @@ jobs: name: Build check runs-on: ubuntu-latest steps: + # Shared backend contract types arrive as a submodule. `types:init` + # re-applies the sparse checkout, which lives in the submodule's .git + # and so does not survive a fresh clone. - uses: actions/checkout@v4 + with: + submodules: true - uses: actions/setup-node@v4 with: node-version: 24 + - run: yarn types:init + - run: yarn install --frozen-lockfile - run: yarn build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06f2153..af681e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,13 +14,20 @@ jobs: contents: write # push the version-bump commit id-token: write # OIDC trusted publishing to npm (no token secret) steps: + # Shared backend contract types arrive as a submodule. `types:init` + # re-applies the sparse checkout, which lives in the submodule's .git + # and so does not survive a fresh clone. - uses: actions/checkout@v4 + with: + submodules: true - uses: actions/setup-node@v4 with: node-version: 24 registry-url: https://registry.npmjs.org + - run: yarn types:init + # Ensure npm supports OIDC trusted publishing (>= 11.5.1). - run: npm install -g npm@11.5.1 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a124787 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "vendor/backend-api"] + path = vendor/backend-api + url = https://github.com/ThatOpen/platform_backend-api.git + branch = feature/notification-dtos diff --git a/package.json b/package.json index e425fd3..4e771ca 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "scripts": { "dev": "vite", "lint": "eslint src/", + "types:update": "./scripts/update-types.sh", + "types:init": "./scripts/update-types.sh --pin-only", "build": "eslint src/ && tsc && vite build && vite build --config vite.config.cli.mts && node scripts/generate-cli-docs-paths.mjs && node scripts/generate-client-examples-paths.mjs", "build:lib": "tsc && vite build", "test": "vitest run", diff --git a/scripts/update-types.sh b/scripts/update-types.sh new file mode 100755 index 0000000..5b1e0c9 --- /dev/null +++ b/scripts/update-types.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Refresh the backend contract types. +# +# The shared DTOs live in the backend repo, so they arrive here as a git +# submodule. Two things this script does that a plain `git submodule update` +# does not: +# +# 1. Applies a sparse checkout, so only `src/common/dto` lands on disk +# instead of the whole backend repository. Sparse config lives in the +# submodule's .git and is not committed, so it has to be re-applied on +# every fresh clone — including in CI. +# 2. Moves the pin to the tip of the tracked branch. Deliberately a command +# you run, not a postinstall hook: an install that silently moved the +# pin would break builds with no commit explaining why. +# +# After running this, `git add vendor/backend-api` and commit the new pin. +set -euo pipefail + +SUBMODULE_PATH="vendor/backend-api" +SPARSE_PATH="src/common/dto" + +git submodule update --init "$SUBMODULE_PATH" + +git -C "$SUBMODULE_PATH" sparse-checkout init --cone +git -C "$SUBMODULE_PATH" sparse-checkout set "$SPARSE_PATH" + +if [ "${1:-}" = "--pin-only" ]; then + echo "Pinned at $(git -C "$SUBMODULE_PATH" rev-parse --short HEAD) (not moved)." + exit 0 +fi + +git submodule update --remote "$SUBMODULE_PATH" +git -C "$SUBMODULE_PATH" sparse-checkout set "$SPARSE_PATH" + +BRANCH=$(git config -f .gitmodules "submodule.$SUBMODULE_PATH.branch") +echo "Types updated from '$BRANCH' at $(git -C "$SUBMODULE_PATH" rev-parse --short HEAD)." +echo "Commit the new pin with: git add $SUBMODULE_PATH" diff --git a/src/core/platform-client.notifications.test.ts b/src/core/platform-client.notifications.test.ts new file mode 100644 index 0000000..682a715 --- /dev/null +++ b/src/core/platform-client.notifications.test.ts @@ -0,0 +1,166 @@ +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, + type Mock, +} from 'vitest'; +import { PlatformClient } from './platform-client'; + +const API = 'https://api.example.com'; +const JWT = 'test-jwt'; + +function okResponse(data: unknown): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify(data), + json: async () => data, + } as unknown as Response; +} + +function callUrl(fetchMock: Mock, index = 0): URL { + return new URL(fetchMock.mock.calls[index][0] as string); +} + +function callInit(fetchMock: Mock, index = 0): RequestInit { + return fetchMock.mock.calls[index][1] as RequestInit; +} + +const emptyPage = { items: [], nextCursor: null }; + +describe('PlatformClient — notifications', () => { + let fetchMock: Mock; + let client: PlatformClient; + + beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + client = new PlatformClient(JWT, API); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getNotifications', () => { + it('reads the account notifications with no query when unpaged', async () => { + fetchMock.mockResolvedValue(okResponse(emptyPage)); + + await client.getNotifications(); + + const url = callUrl(fetchMock); + expect(url.pathname).toContain('/notifications'); + expect(url.searchParams.get('cursor')).toBeNull(); + expect(url.searchParams.get('limit')).toBeNull(); + }); + + it('passes cursor and limit through', async () => { + fetchMock.mockResolvedValue(okResponse(emptyPage)); + + await client.getNotifications({ cursor: 'abc_123', limit: 50 }); + + const url = callUrl(fetchMock); + expect(url.searchParams.get('cursor')).toBe('abc_123'); + expect(url.searchParams.get('limit')).toBe('50'); + }); + + // The cursor is opaque and round-trips verbatim; anything that mangles it + // silently breaks pagination rather than erroring. + it('does not mangle a cursor containing an ISO timestamp', async () => { + fetchMock.mockResolvedValue(okResponse(emptyPage)); + const cursor = '2026-08-12T09:30:00.000Z_6a7c95b780c5fd7e84758c32'; + + await client.getNotifications({ cursor }); + + expect(callUrl(fetchMock).searchParams.get('cursor')).toBe(cursor); + }); + + it('returns the page as sent, ids and timestamps as strings', async () => { + const page = { + items: [ + { + _id: '6a7c95b780c5fd7e84758c32', + accountId: '6a3bb8b0f32c03c0f86897f2', + type: 'automation.run.finished', + category: 'automation', + title: 'Nightly report failed', + body: 'IFC Converter ended with ERROR.', + link: '/dashboard/projects/p1/automation-runs', + muted: false, + readAt: null, + createdAt: '2026-08-12T09:30:00.000Z', + }, + ], + nextCursor: null, + }; + fetchMock.mockResolvedValue(okResponse(page)); + + const result = await client.getNotifications(); + + expect(result).toEqual(page); + expect(typeof result.items[0]._id).toBe('string'); + expect(typeof result.items[0].createdAt).toBe('string'); + }); + }); + + it('unwraps the unread count to a number', async () => { + fetchMock.mockResolvedValue(okResponse({ count: 7 })); + + await expect(client.getUnreadNotificationCount()).resolves.toBe(7); + expect(callUrl(fetchMock).pathname).toContain('/notifications/unread-count'); + }); + + it('marks specific notifications read as a JSON body', async () => { + fetchMock.mockResolvedValue(okResponse({ updated: 2 })); + + const result = await client.markNotificationsRead(['id-1', 'id-2']); + + const init = callInit(fetchMock); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ ids: ['id-1', 'id-2'] }); + expect(result.updated).toBe(2); + }); + + it('marks all read without a body', async () => { + fetchMock.mockResolvedValue(okResponse({ updated: 9 })); + + await client.markAllNotificationsRead(); + + expect(callUrl(fetchMock).pathname).toContain( + '/notifications/mark-all-read', + ); + expect(callInit(fetchMock).method).toBe('POST'); + }); + + it('lists subscriptions', async () => { + fetchMock.mockResolvedValue(okResponse([])); + + await expect(client.getNotificationSubscriptions()).resolves.toEqual([]); + expect(callUrl(fetchMock).pathname).toContain('/notifications/subscriptions'); + }); + + it('unsubscribes by hook id', async () => { + fetchMock.mockResolvedValue(okResponse({ unsubscribed: true })); + + const result = await client.unsubscribeFromAutomation('hook-1'); + + expect(callInit(fetchMock).method).toBe('DELETE'); + expect(callUrl(fetchMock).pathname).toContain( + '/notifications/subscriptions/hook-1', + ); + expect(result.unsubscribed).toBe(true); + }); + + it('sends the bearer token on notification routes', async () => { + fetchMock.mockResolvedValue(okResponse(emptyPage)); + + await client.getNotifications(); + + const headers = callInit(fetchMock).headers as Record; + expect(headers.Authorization).toBe(`Bearer ${JWT}`); + }); +}); diff --git a/src/core/platform-client.ts b/src/core/platform-client.ts index f72d893..b707ef3 100644 --- a/src/core/platform-client.ts +++ b/src/core/platform-client.ts @@ -4,8 +4,15 @@ import { } from './client'; import { Project, ProjectData } from '../types/projects'; import { ThatOpenContext } from '../types/context'; +import { + MarkNotificationsReadResultDto, + NotificationPageDto, + NotificationSubscriptionView, + UnreadCountDto, +} from '../types/notifications'; const PROJECT_PATH = 'project'; +const NOTIFICATION_PATH = 'notifications'; /** Scope by which a permission was granted (or `'none'` if denied). */ export type PermissionScope = 'global' | 'project' | 'entity' | 'none'; @@ -187,4 +194,98 @@ export class PlatformClient extends EngineServicesClient { }); return response.results; } + + // ─── Notifications ──────────────────────────────────────────────── + + /** + * Lists the signed-in user's notifications, newest first. + * + * Scoped to whoever the bearer token belongs to — an app cannot read + * anyone else's. Muted notifications are included: muting silences the + * badge and the delivery channels, it does not hide the record. + * + * Paginate by passing the previous response's `nextCursor` back in; it is + * opaque, so do not build one by hand. A null `nextCursor` means the last + * page. + * + * @example Walk every page: + * ```ts + * let cursor: string | undefined; + * do { + * const page = await client.getNotifications({ cursor }); + * render(page.items); + * cursor = page.nextCursor ?? undefined; + * } while (cursor); + * ``` + */ + async getNotifications(params?: { cursor?: string; limit?: number }) { + return await this.request('GET', NOTIFICATION_PATH, { + query: { + ...(params?.cursor !== undefined && { cursor: params.cursor }), + ...(params?.limit !== undefined && { limit: String(params.limit) }), + }, + }); + } + + /** + * Number of unread notifications, excluding muted ones. This is the bell + * badge count, so it is cheap to poll. + */ + async getUnreadNotificationCount() { + const response = await this.request( + 'GET', + `${NOTIFICATION_PATH}/unread-count`, + ); + return response.count; + } + + /** + * Marks specific notifications as read. Ids the caller does not own are + * ignored rather than rejected, so `updated` can be lower than the number + * passed in. + */ + async markNotificationsRead(notificationIds: string[]) { + return await this.request( + 'POST', + `${NOTIFICATION_PATH}/mark-read`, + { + body: JSON.stringify({ ids: notificationIds }), + contentType: 'application/json', + }, + ); + } + + /** Marks every unread notification as read in one call. */ + async markAllNotificationsRead() { + return await this.request( + 'POST', + `${NOTIFICATION_PATH}/mark-all-read`, + ); + } + + /** + * The automations this user has subscribed to. Nobody is subscribed by + * default, so an empty list is the normal state. + */ + async getNotificationSubscriptions() { + return await this.request( + 'GET', + `${NOTIFICATION_PATH}/subscriptions`, + ); + } + + /** + * Removes this user's subscription to one automation. + * + * Unlike subscribing, which happens through the project routes, this works + * for any automation the user is subscribed to and keeps working after the + * automation itself is gone — opting out must never be the thing that + * fails. + */ + async unsubscribeFromAutomation(hookId: string) { + return await this.request<{ unsubscribed: boolean }>( + 'DELETE', + `${NOTIFICATION_PATH}/subscriptions/${hookId}`, + ); + } } diff --git a/src/index.ts b/src/index.ts index ef82edf..6f6bbf0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,4 +10,5 @@ export * from './types/item.dto'; export * from './types/projects'; export * from './types/context'; export * from './types/npm'; +export * from './types/notifications'; export * from './built-in'; diff --git a/src/types/notifications.ts b/src/types/notifications.ts new file mode 100644 index 0000000..8571e58 --- /dev/null +++ b/src/types/notifications.ts @@ -0,0 +1,39 @@ +// Re-exported from the backend rather than redeclared here. +// +// Every other file in this directory is a hand-maintained copy of a backend +// type, which is why `base.ts` has to alias `ObjectId` to `string` and hope +// it stays true. These come straight from `src/common/dto` in the backend +// repo, vendored as a submodule, so the contract cannot drift from what the +// API actually sends. +// +// Run `yarn types:update` to move the pin. Nothing in that directory imports +// `mongodb`, which is what makes it safe to compile in a browser build. +export type { + NotificationCategoryDto, + NotificationDto, + NotificationPageDto, + NotificationTypeDto, + MarkNotificationsReadResultDto, + UnreadCountDto, +} from '../../vendor/backend-api/src/common/dto/notifications.dto'; + +export type { BaseDto } from '../../vendor/backend-api/src/common/dto/base.dto'; + +/** A user's opt-in to one automation's runs, as the API returns it. */ +export interface NotificationSubscriptionView { + _id: string; + accountId: string; + hookId: string; + /** Absent for personal automations, which belong to an account. */ + projectId?: string; + filter: NotificationSubscriptionFilter; + channels?: { email?: boolean }; + createdAt: string; + updatedAt?: string; +} + +/** + * `failures` suppresses started events entirely and only passes a finished + * run that did not succeed. + */ +export type NotificationSubscriptionFilter = 'all' | 'failures'; diff --git a/tsconfig.json b/tsconfig.json index b9ffc4f..90d26ee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,10 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src", "test.local.dev/clientus.ts"], + "include": [ + "src", + "vendor/backend-api/src/common/dto", + "test.local.dev/clientus.ts" + ], "exclude": ["src/cli/templates", "src/built-in/**/example.ts"] } diff --git a/vendor/backend-api b/vendor/backend-api new file mode 160000 index 0000000..0b89d2b --- /dev/null +++ b/vendor/backend-api @@ -0,0 +1 @@ +Subproject commit 0b89d2bc66174e388a934f0dc9affec72d3cc894 diff --git a/vite.config.mts b/vite.config.mts index ac055c7..8b876f1 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -18,7 +18,11 @@ export default defineConfig({ plugins: [ dts({ insertTypesEntry: true, - include: ['src'], + // The vendored backend DTOs are re-exported from src/types/notifications, + // so their declarations have to be emitted too. Without this the + // published .d.ts points at ../../vendor/... and resolves to nothing on + // a consumer's machine. + include: ['src', 'vendor/backend-api/src/common/dto'], copyDtsFiles: true, }), ], From 8df588e233ed05e2cd5a165f93df8da1e34a5ceb Mon Sep 17 00:00:00 2001 From: abujalance Date: Thu, 13 Aug 2026 14:38:20 +0200 Subject: [PATCH 2/7] chore: track the backend contract from dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend-api#321 is merged, so the DTOs no longer live on a feature branch. Tracking dev rather than main because that is where they are: main gets them with the next release, and repointing is a one-line change here plus yarn types:update. Pin includes the review fixes on that PR — the base.dto helpers are gone and the type union guard is bidirectional. --- .gitmodules | 2 +- vendor/backend-api | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index a124787..ac4a1a9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "vendor/backend-api"] path = vendor/backend-api url = https://github.com/ThatOpen/platform_backend-api.git - branch = feature/notification-dtos + branch = dev diff --git a/vendor/backend-api b/vendor/backend-api index 0b89d2b..e8d1b25 160000 --- a/vendor/backend-api +++ b/vendor/backend-api @@ -1 +1 @@ -Subproject commit 0b89d2bc66174e388a934f0dc9affec72d3cc894 +Subproject commit e8d1b25ab2a35f372fe4fe493ed5312fb9b54f2a From b97077de805868139cea26634e281d73d91b56aa Mon Sep 17 00:00:00 2001 From: abujalance Date: Thu, 13 Aug 2026 14:39:56 +0200 Subject: [PATCH 3/7] ci: authenticate the cross-repo submodule fetch The backend repo is private, so the default GITHUB_TOKEN cannot clone it and checkout with submodules: true fails outright with "Repository not found". Credential is scoped to the fetch step via url.insteadOf and unset immediately after, rather than handed to actions/checkout. Passing it to checkout would make it the identity for everything else in the job, including the release workflow's version-bump push. Needs a BACKEND_TYPES_TOKEN secret on this repo with read access to contents on platform_backend-api. --- .github/workflows/ci.yml | 20 ++++++++++++++------ .github/workflows/release.yml | 20 ++++++++++++++------ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1b0008..1602b7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,18 +9,26 @@ jobs: name: Build check runs-on: ubuntu-latest steps: - # Shared backend contract types arrive as a submodule. `types:init` - # re-applies the sparse checkout, which lives in the submodule's .git - # and so does not survive a fresh clone. - uses: actions/checkout@v4 - with: - submodules: true - uses: actions/setup-node@v4 with: node-version: 24 - - run: yarn types:init + # Shared backend contract types arrive as a submodule pointing at a + # private repo, so the default GITHUB_TOKEN cannot clone it. The + # credential is scoped to this step rather than passed to checkout, so + # it never becomes the identity for anything else in the job. + # + # types:init also re-applies the sparse checkout, which lives in the + # submodule's .git and does not survive a fresh clone. + - name: Fetch backend contract types + env: + BACKEND_TYPES_TOKEN: ${{ secrets.BACKEND_TYPES_TOKEN }} + run: | + git config --global url."https://x-access-token:${BACKEND_TYPES_TOKEN}@github.com/".insteadOf "https://github.com/" + yarn types:init + git config --global --unset-all url."https://x-access-token:${BACKEND_TYPES_TOKEN}@github.com/".insteadOf - run: yarn install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af681e6..a1b1606 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,19 +14,27 @@ jobs: contents: write # push the version-bump commit id-token: write # OIDC trusted publishing to npm (no token secret) steps: - # Shared backend contract types arrive as a submodule. `types:init` - # re-applies the sparse checkout, which lives in the submodule's .git - # and so does not survive a fresh clone. - uses: actions/checkout@v4 - with: - submodules: true - uses: actions/setup-node@v4 with: node-version: 24 registry-url: https://registry.npmjs.org - - run: yarn types:init + # Shared backend contract types arrive as a submodule pointing at a + # private repo, so the default GITHUB_TOKEN cannot clone it. The + # credential is scoped to this step rather than passed to checkout, so + # it never becomes the identity for anything else in the job. + # + # types:init also re-applies the sparse checkout, which lives in the + # submodule's .git and does not survive a fresh clone. + - name: Fetch backend contract types + env: + BACKEND_TYPES_TOKEN: ${{ secrets.BACKEND_TYPES_TOKEN }} + run: | + git config --global url."https://x-access-token:${BACKEND_TYPES_TOKEN}@github.com/".insteadOf "https://github.com/" + yarn types:init + git config --global --unset-all url."https://x-access-token:${BACKEND_TYPES_TOKEN}@github.com/".insteadOf # Ensure npm supports OIDC trusted publishing (>= 11.5.1). - run: npm install -g npm@11.5.1 From dbe6e667ff84cd9c801ed4524d58c81a050d81f6 Mon Sep 17 00:00:00 2001 From: abujalance Date: Thu, 13 Aug 2026 15:59:36 +0200 Subject: [PATCH 4/7] ci: explain how to fix a missing backend-types credential The failure was a bare "Repository not found" from git, which says nothing about the cause or the fix. CI now fails with an annotation, a job summary and step-by-step setup instructions. Also accepts a deploy key as an alternative to the token. A read-only deploy key on the backend repo never expires and belongs to the repo rather than to whoever created it, so the yearly token rotation goes away. Both work by rewriting the https URL rather than editing .gitmodules, so cloning over https locally is unaffected. The credential handling moved out of the workflows and into the script, so the two workflows cannot drift and the guidance sits next to the failure. --- .github/workflows/ci.yml | 17 ++---- .github/workflows/release.yml | 17 ++---- scripts/update-types.sh | 109 +++++++++++++++++++++++++++++++++- 3 files changed, 119 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1602b7b..73bf4f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,20 +15,15 @@ jobs: with: node-version: 24 - # Shared backend contract types arrive as a submodule pointing at a - # private repo, so the default GITHUB_TOKEN cannot clone it. The - # credential is scoped to this step rather than passed to checkout, so - # it never becomes the identity for anything else in the job. - # - # types:init also re-applies the sparse checkout, which lives in the - # submodule's .git and does not survive a fresh clone. + # The backend repo is private, so the default GITHUB_TOKEN cannot clone + # the submodule. Either secret works; the script picks whichever is set + # and prints setup instructions if neither is. It also re-applies the + # sparse checkout, which does not survive a fresh clone. - name: Fetch backend contract types env: + BACKEND_TYPES_DEPLOY_KEY: ${{ secrets.BACKEND_TYPES_DEPLOY_KEY }} BACKEND_TYPES_TOKEN: ${{ secrets.BACKEND_TYPES_TOKEN }} - run: | - git config --global url."https://x-access-token:${BACKEND_TYPES_TOKEN}@github.com/".insteadOf "https://github.com/" - yarn types:init - git config --global --unset-all url."https://x-access-token:${BACKEND_TYPES_TOKEN}@github.com/".insteadOf + run: yarn types:init - run: yarn install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1b1606..7505bbf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,20 +21,15 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org - # Shared backend contract types arrive as a submodule pointing at a - # private repo, so the default GITHUB_TOKEN cannot clone it. The - # credential is scoped to this step rather than passed to checkout, so - # it never becomes the identity for anything else in the job. - # - # types:init also re-applies the sparse checkout, which lives in the - # submodule's .git and does not survive a fresh clone. + # The backend repo is private, so the default GITHUB_TOKEN cannot clone + # the submodule. Either secret works; the script picks whichever is set + # and prints setup instructions if neither is. It also re-applies the + # sparse checkout, which does not survive a fresh clone. - name: Fetch backend contract types env: + BACKEND_TYPES_DEPLOY_KEY: ${{ secrets.BACKEND_TYPES_DEPLOY_KEY }} BACKEND_TYPES_TOKEN: ${{ secrets.BACKEND_TYPES_TOKEN }} - run: | - git config --global url."https://x-access-token:${BACKEND_TYPES_TOKEN}@github.com/".insteadOf "https://github.com/" - yarn types:init - git config --global --unset-all url."https://x-access-token:${BACKEND_TYPES_TOKEN}@github.com/".insteadOf + run: yarn types:init # Ensure npm supports OIDC trusted publishing (>= 11.5.1). - run: npm install -g npm@11.5.1 diff --git a/scripts/update-types.sh b/scripts/update-types.sh index 5b1e0c9..f0f7936 100755 --- a/scripts/update-types.sh +++ b/scripts/update-types.sh @@ -18,8 +18,111 @@ set -euo pipefail SUBMODULE_PATH="vendor/backend-api" SPARSE_PATH="src/common/dto" +BACKEND_REPO="ThatOpen/platform_backend-api" +KEY_SECRET="BACKEND_TYPES_DEPLOY_KEY" +TOKEN_SECRET="BACKEND_TYPES_TOKEN" -git submodule update --init "$SUBMODULE_PATH" +# The backend repo is private. Locally that is fine, git uses whatever +# credentials you already have. In CI there is no such thing, and the failure +# is a bare "Repository not found" that says nothing about what to do, so +# spell it out instead. +token_instructions() { + cat <}/settings/secrets/actions + Name : ${KEY_SECRET} + Value: contents of backend-types (the file without .pub) + + 4. Delete both local files and re-run this job. + + Alternative, a fine-grained token. Quicker, but expires within a year and + is tied to whoever made it: + + 1. https://github.com/settings/personal-access-tokens/new + Resource owner : ThatOpen + Repository access : Only select repositories -> platform_backend-api + Repository permissions: Contents -> Read-only + 2. Store it as ${TOKEN_SECRET} in this repo's Actions secrets. + Because the owner is the organisation, it may sit in "pending + approval" until an org owner accepts it. + + If one of these is already set, it has most likely been revoked, or lost + access to ${BACKEND_REPO}. A token may simply have expired. + +INSTRUCTIONS +} + +fail_with_instructions() { + local headline="$1" + if [ -n "${GITHUB_ACTIONS:-}" ]; then + echo "::error title=Backend types unavailable::${headline} See the log for how to fix it." + token_instructions + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "## Backend contract types could not be fetched" + echo + echo "**${headline}**" + echo '```' + token_instructions + echo '```' + } >>"$GITHUB_STEP_SUMMARY" + fi + else + echo "${headline}" + token_instructions + fi + exit 1 +} + +# Only enforced in CI. A developer's own git credentials already cover the +# private repo, so requiring the token locally would be noise. +if [ -n "${CI:-}" ] && + [ -z "${BACKEND_TYPES_DEPLOY_KEY:-}" ] && + [ -z "${BACKEND_TYPES_TOKEN:-}" ]; then + fail_with_instructions \ + "Neither ${KEY_SECRET} nor ${TOKEN_SECRET} is set." +fi + +# Both rewrite the submodule's https URL rather than changing .gitmodules, so +# a developer cloning over https locally is unaffected. Scoped to this +# process and undone on the way out, so the credential never becomes the +# identity for anything else in the job. +if [ -n "${BACKEND_TYPES_DEPLOY_KEY:-}" ]; then + KEY_FILE=$(mktemp) + printf '%s\n' "$BACKEND_TYPES_DEPLOY_KEY" >"$KEY_FILE" + chmod 600 "$KEY_FILE" + export GIT_SSH_COMMAND="ssh -i $KEY_FILE -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" + git config --global "url.git@github.com:.insteadOf" "https://github.com/" + trap 'rm -f "$KEY_FILE"; git config --global --unset-all "url.git@github.com:.insteadOf" || true' EXIT +elif [ -n "${BACKEND_TYPES_TOKEN:-}" ]; then + git config --global \ + "url.https://x-access-token:${BACKEND_TYPES_TOKEN}@github.com/.insteadOf" \ + "https://github.com/" + trap 'git config --global --unset-all "url.https://x-access-token:${BACKEND_TYPES_TOKEN}@github.com/.insteadOf" || true' EXIT +fi + +if ! git submodule update --init "$SUBMODULE_PATH"; then + fail_with_instructions "Could not clone the backend types submodule." +fi git -C "$SUBMODULE_PATH" sparse-checkout init --cone git -C "$SUBMODULE_PATH" sparse-checkout set "$SPARSE_PATH" @@ -29,7 +132,9 @@ if [ "${1:-}" = "--pin-only" ]; then exit 0 fi -git submodule update --remote "$SUBMODULE_PATH" +if ! git submodule update --remote "$SUBMODULE_PATH"; then + fail_with_instructions "Could not update the backend types submodule." +fi git -C "$SUBMODULE_PATH" sparse-checkout set "$SPARSE_PATH" BRANCH=$(git config -f .gitmodules "submodule.$SUBMODULE_PATH.branch") From 57825a2a19cb73eb568f6a2e38ee22c935ea51aa Mon Sep 17 00:00:00 2001 From: abujalance Date: Thu, 13 Aug 2026 16:08:34 +0200 Subject: [PATCH 5/7] ci: mint the backend-types credential from a GitHub App Replaces the standing token with an App-derived one, scoped to platform_backend-api and valid for an hour, so the only long-lived secret is an App key that grants nothing by itself. A plain token or a deploy key still work if those secrets are set instead. Failure instructions now lead with the App setup. --- .github/workflows/ci.yml | 19 ++++++++++++--- .github/workflows/release.yml | 19 ++++++++++++--- scripts/update-types.sh | 46 ++++++++++++++++++++--------------- 3 files changed, 57 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73bf4f9..16993c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,13 +16,24 @@ jobs: node-version: 24 # The backend repo is private, so the default GITHUB_TOKEN cannot clone - # the submodule. Either secret works; the script picks whichever is set - # and prints setup instructions if neither is. It also re-applies the - # sparse checkout, which does not survive a fresh clone. + # the submodule. A GitHub App mints a token scoped to that one repo and + # valid for an hour, so nothing long-lived than the App key is stored. + - uses: actions/create-github-app-token@v1 + id: backend-types-token + with: + app-id: ${{ secrets.BACKEND_TYPES_APP_ID }} + private-key: ${{ secrets.BACKEND_TYPES_APP_PRIVATE_KEY }} + owner: ThatOpen + repositories: platform_backend-api + + # Falls back to a plain token or a deploy key if those secrets are set + # instead. The script picks whichever it finds and prints setup + # instructions if it finds none. It also re-applies the sparse checkout, + # which does not survive a fresh clone. - name: Fetch backend contract types env: + BACKEND_TYPES_TOKEN: ${{ steps.backend-types-token.outputs.token || secrets.BACKEND_TYPES_TOKEN }} BACKEND_TYPES_DEPLOY_KEY: ${{ secrets.BACKEND_TYPES_DEPLOY_KEY }} - BACKEND_TYPES_TOKEN: ${{ secrets.BACKEND_TYPES_TOKEN }} run: yarn types:init - run: yarn install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7505bbf..ba1958f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,13 +22,24 @@ jobs: registry-url: https://registry.npmjs.org # The backend repo is private, so the default GITHUB_TOKEN cannot clone - # the submodule. Either secret works; the script picks whichever is set - # and prints setup instructions if neither is. It also re-applies the - # sparse checkout, which does not survive a fresh clone. + # the submodule. A GitHub App mints a token scoped to that one repo and + # valid for an hour, so nothing long-lived than the App key is stored. + - uses: actions/create-github-app-token@v1 + id: backend-types-token + with: + app-id: ${{ secrets.BACKEND_TYPES_APP_ID }} + private-key: ${{ secrets.BACKEND_TYPES_APP_PRIVATE_KEY }} + owner: ThatOpen + repositories: platform_backend-api + + # Falls back to a plain token or a deploy key if those secrets are set + # instead. The script picks whichever it finds and prints setup + # instructions if it finds none. It also re-applies the sparse checkout, + # which does not survive a fresh clone. - name: Fetch backend contract types env: + BACKEND_TYPES_TOKEN: ${{ steps.backend-types-token.outputs.token || secrets.BACKEND_TYPES_TOKEN }} BACKEND_TYPES_DEPLOY_KEY: ${{ secrets.BACKEND_TYPES_DEPLOY_KEY }} - BACKEND_TYPES_TOKEN: ${{ secrets.BACKEND_TYPES_TOKEN }} run: yarn types:init # Ensure npm supports OIDC trusted publishing (>= 11.5.1). diff --git a/scripts/update-types.sh b/scripts/update-types.sh index f0f7936..c4832dc 100755 --- a/scripts/update-types.sh +++ b/scripts/update-types.sh @@ -20,6 +20,8 @@ SUBMODULE_PATH="vendor/backend-api" SPARSE_PATH="src/common/dto" BACKEND_REPO="ThatOpen/platform_backend-api" KEY_SECRET="BACKEND_TYPES_DEPLOY_KEY" +APP_ID_SECRET="BACKEND_TYPES_APP_ID" +APP_KEY_SECRET="BACKEND_TYPES_APP_PRIVATE_KEY" TOKEN_SECRET="BACKEND_TYPES_TOKEN" # The backend repo is private. Locally that is fine, git uses whatever @@ -35,37 +37,43 @@ token_instructions() { is private, so CI needs a token with read access to it. The default GITHUB_TOKEN cannot see other repositories. - Preferred fix, a deploy key. It never expires and belongs to the repo - rather than to a person: + Preferred fix, a GitHub App. Nothing long-lived is granted: the workflow + mints a token scoped to that one repo, valid for an hour. - 1. Generate a keypair (leave the passphrase empty): - ssh-keygen -t ed25519 -N "" -C "platform_services types" -f backend-types + 1. https://github.com/organizations/ThatOpen/settings/apps/new + Name : anything unique, e.g. "ThatOpen CI types reader" + Homepage URL: https://github.com/ThatOpen + Webhook : UNTICK "Active", or it demands a webhook URL + Permissions -> Repository -> Contents: Read-only (nothing else) + Where installed: Only on this account - 2. Register the PUBLIC half as a read-only deploy key: - https://github.com/${BACKEND_REPO}/settings/keys/new - Title : platform_services contract types - Key : contents of backend-types.pub - Allow write : NO + 2. On the App page, note the App ID, then "Generate a private key". + That downloads a .pem file. - 3. Add the PRIVATE half here: + 3. Install App -> ThatOpen -> Only select repositories -> + platform_backend-api + + 4. Add two secrets here: ${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-}/settings/secrets/actions - Name : ${KEY_SECRET} - Value: contents of backend-types (the file without .pub) + ${APP_ID_SECRET} : the App ID from step 2 + ${APP_KEY_SECRET} : the whole .pem, BEGIN and END lines included - 4. Delete both local files and re-run this job. + 5. Delete the .pem locally and re-run this job. - Alternative, a fine-grained token. Quicker, but expires within a year and - is tied to whoever made it: + Quicker alternative, a fine-grained token. Expires within a year and is + tied to whoever made it: 1. https://github.com/settings/personal-access-tokens/new Resource owner : ThatOpen Repository access : Only select repositories -> platform_backend-api Repository permissions: Contents -> Read-only - 2. Store it as ${TOKEN_SECRET} in this repo's Actions secrets. - Because the owner is the organisation, it may sit in "pending - approval" until an org owner accepts it. + 2. Store it as ${TOKEN_SECRET} in this repo's Actions secrets. Because + the owner is the organisation, it may sit in "pending approval" + until an org owner accepts it. + + A read-only deploy key stored as ${KEY_SECRET} also works. - If one of these is already set, it has most likely been revoked, or lost + If one of these is already set, it has most likely been revoked or lost access to ${BACKEND_REPO}. A token may simply have expired. INSTRUCTIONS From 9b074a3b327a20a46f1fe9da62beb82da81f0042 Mon Sep 17 00:00:00 2001 From: abujalance Date: Thu, 13 Aug 2026 16:37:04 +0200 Subject: [PATCH 6/7] feat: subscribe to automations and listen for notifications live Three methods the client was missing. subscribeToAutomation and updateAutomationSubscription cover the project routes that have been live since task 04. Without them an app could list and cancel subscriptions but never create one, which is a daft half of the feature. onNotification opens the /notifications socket namespace added in backend-api#325. It is deliberately unlike onExecutionProgress: that one follows a single execution and closes when it ends, while a bell stays connected for the session, so this returns a disconnect function instead of managing its own lifecycle. Events carry ids rather than notifications, so a burst stays cheap and the socket is never the source of a stale render. The token is resolved per connection so a provider-backed client opens with a current one. --- .changeset/notifications-subscribe-live.md | 10 ++ src/core/client.ts | 9 ++ src/core/platform-client.live.test.ts | 74 ++++++++++ .../platform-client.notifications.test.ts | 50 ++++++- src/core/platform-client.ts | 127 ++++++++++++++++++ 5 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 .changeset/notifications-subscribe-live.md create mode 100644 src/core/platform-client.live.test.ts diff --git a/.changeset/notifications-subscribe-live.md b/.changeset/notifications-subscribe-live.md new file mode 100644 index 0000000..24688ea --- /dev/null +++ b/.changeset/notifications-subscribe-live.md @@ -0,0 +1,10 @@ +--- +'@thatopen/services': minor +--- + +Add `subscribeToAutomation` and `updateAutomationSubscription`, so an app can +create a subscription rather than only listing and cancelling one. + +Add `onNotification`, a live socket subscription for the signed-in user. +Unlike `onExecutionProgress` it stays connected for the session rather than +closing on a terminal event, and it returns a function that disconnects. diff --git a/src/core/client.ts b/src/core/client.ts index 6daf2cf..1835771 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -299,6 +299,15 @@ export class EngineServicesClient { * the new token is picked up on every request — expired tokens no * longer stick around. */ + /** + * Socket origin without namespace or query, for gateways other than the + * execution one. `wsUrl` already carries a token that may be stale when a + * provider is in play, so callers append their own. + */ + protected get socketOrigin(): string { + return this.wsUrl.split('?')[0]; + } + protected async resolveAccessToken(): Promise { return this.accessToken; } diff --git a/src/core/platform-client.live.test.ts b/src/core/platform-client.live.test.ts new file mode 100644 index 0000000..108d50d --- /dev/null +++ b/src/core/platform-client.live.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const handlers = new Map void>(); +const disconnect = vi.fn(); +const ioMock = vi.fn(() => ({ + on: (event: string, handler: (payload: unknown) => void) => { + handlers.set(event, handler); + }, + disconnect, +})); + +vi.mock('socket.io-client', () => ({ io: (...args: unknown[]) => ioMock(...(args as [])) })); + +const { PlatformClient } = await import('./platform-client'); + +const API = 'https://api.example.com'; + +describe('PlatformClient — live notifications', () => { + let client: InstanceType; + + beforeEach(() => { + handlers.clear(); + ioMock.mockClear(); + disconnect.mockClear(); + client = new PlatformClient('jwt-1', API); + }); + + it('connects to the notifications namespace with the token', async () => { + await client.onNotification(() => {}); + + const url = (ioMock.mock.calls[0] as unknown as string[])[0]; + expect(url).toContain('/notifications'); + expect(url).toContain('accessToken=jwt-1'); + // No /api on a socket URL; that prefix is for REST only. + expect(url).not.toContain('/api/'); + }); + + // A provider-backed client must open the socket with a current token, not + // the one it happened to be constructed with. + it('resolves the token per connection when a provider is used', async () => { + const provider = vi.fn().mockResolvedValue('fresh-token'); + const providerClient = new PlatformClient(provider, API); + + await providerClient.onNotification(() => {}); + + expect(provider).toHaveBeenCalled(); + expect((ioMock.mock.calls[0] as unknown as string[])[0]).toContain( + 'accessToken=fresh-token', + ); + }); + + it('maps each server event onto one callback shape', async () => { + const seen: unknown[] = []; + await client.onNotification((event) => seen.push(event)); + + handlers.get('notification.created')?.({ id: 'n1' }); + handlers.get('notification.read')?.({ id: 'n2' }); + handlers.get('notifications.allRead')?.({ batch: 42 }); + + expect(seen).toEqual([ + { type: 'created', id: 'n1' }, + { type: 'read', id: 'n2' }, + { type: 'allRead', batch: 42 }, + ]); + }); + + it('returns a disconnect function', async () => { + const stop = await client.onNotification(() => {}); + + expect(disconnect).not.toHaveBeenCalled(); + stop(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/platform-client.notifications.test.ts b/src/core/platform-client.notifications.test.ts index 682a715..ca41e97 100644 --- a/src/core/platform-client.notifications.test.ts +++ b/src/core/platform-client.notifications.test.ts @@ -163,4 +163,52 @@ describe('PlatformClient — notifications', () => { const headers = callInit(fetchMock).headers as Record; expect(headers.Authorization).toBe(`Bearer ${JWT}`); }); -}); + + describe('subscribing to an automation', () => { + const PROJECT = 'proj-1'; + const HOOK = 'hook-1'; + + it('subscribes through the project route', async () => { + fetchMock.mockResolvedValue(okResponse({ subscribed: true })); + + await client.subscribeToAutomation(PROJECT, HOOK, { + filter: 'failures', + channels: { email: true }, + }); + + const init = callInit(fetchMock); + expect(init.method).toBe('POST'); + expect(callUrl(fetchMock).pathname).toContain( + `/project/${PROJECT}/events/hooks/${HOOK}/subscription`, + ); + expect(JSON.parse(init.body as string)).toEqual({ + filter: 'failures', + channels: { email: true }, + }); + }); + + it('sends an empty body when no options are given', async () => { + fetchMock.mockResolvedValue(okResponse({ subscribed: true })); + + await client.subscribeToAutomation(PROJECT, HOOK); + + expect(JSON.parse(callInit(fetchMock).body as string)).toEqual({}); + }); + + // PATCH is a merge server-side, so sending channels alone must not carry + // a filter along with it and reset one that was already set. + it('patches only what it is given', async () => { + fetchMock.mockResolvedValue(okResponse({ updated: true })); + + await client.updateAutomationSubscription(PROJECT, HOOK, { + channels: { email: false }, + }); + + const init = callInit(fetchMock); + expect(init.method).toBe('PATCH'); + expect(JSON.parse(init.body as string)).toEqual({ + channels: { email: false }, + }); + }); + }); +}); \ No newline at end of file diff --git a/src/core/platform-client.ts b/src/core/platform-client.ts index b707ef3..e1a3123 100644 --- a/src/core/platform-client.ts +++ b/src/core/platform-client.ts @@ -1,3 +1,4 @@ +import { io } from 'socket.io-client'; import { EngineServicesClient, EngineServicesClientProps, @@ -14,6 +15,27 @@ import { const PROJECT_PATH = 'project'; const NOTIFICATION_PATH = 'notifications'; +/** + * What arrived on the socket. One callback for all three because a bell + * reacts the same way to each: refresh the badge, and the list if open. + * + * `allRead` is one event for the whole sweep rather than one per + * notification, so do not expect an id on it. + */ +export type LiveNotificationEvent = + | { type: 'created'; id: string } + | { type: 'read'; id: string } + | { type: 'allRead'; batch: number }; + +/** Per-automation opt-in. `failures` skips started events entirely. */ +export type NotificationSubscriptionFilterInput = 'all' | 'failures'; + +export interface NotificationSubscriptionInput { + filter?: NotificationSubscriptionFilterInput; + /** Overrides the account-level channel setting for this automation only. */ + channels?: { email?: boolean }; +} + /** Scope by which a permission was granted (or `'none'` if denied). */ export type PermissionScope = 'global' | 'project' | 'entity' | 'none'; @@ -288,4 +310,109 @@ export class PlatformClient extends EngineServicesClient { `${NOTIFICATION_PATH}/subscriptions/${hookId}`, ); } + + /** + * Subscribes the signed-in user to one project automation's runs. + * + * Nobody is subscribed by default, so this is what makes an automation + * produce notifications for this user at all. Subscribing again is + * harmless: it updates the existing subscription rather than duplicating. + * + * Read-level access to the project is enough. Someone who can see an + * automation can follow it without being able to change it. + * + * @example Follow only the failures, and email me about them: + * ```ts + * await client.subscribeToAutomation(projectId, hookId, { + * filter: 'failures', + * channels: { email: true }, + * }); + * ``` + */ + async subscribeToAutomation( + projectId: string, + hookId: string, + input?: NotificationSubscriptionInput, + ) { + return await this.request<{ subscribed: true }>( + 'POST', + `${PROJECT_PATH}/${projectId}/events/hooks/${hookId}/subscription`, + { + body: JSON.stringify(input ?? {}), + contentType: 'application/json', + }, + ); + } + + /** + * Changes an existing subscription. Only the fields passed are touched, so + * sending `channels` alone leaves the filter as it was. + * + * Throws if the user is not subscribed; use {@link subscribeToAutomation} + * to create one. + */ + async updateAutomationSubscription( + projectId: string, + hookId: string, + changes: NotificationSubscriptionInput, + ) { + return await this.request<{ updated: true }>( + 'PATCH', + `${PROJECT_PATH}/${projectId}/events/hooks/${hookId}/subscription`, + { + body: JSON.stringify(changes), + contentType: 'application/json', + }, + ); + } + + /** + * Listens for this user's notifications in real time. + * + * Unlike {@link EngineServicesClient.onExecutionProgress}, which follows one + * execution and closes when it ends, this stays connected for the session: + * the server puts the socket in a room for the signed-in account and pushes + * anything addressed to them. + * + * The events carry ids rather than the notifications themselves, so treat + * them as a signal to refresh. That keeps a burst cheap and means the + * server is never the source of a stale render. + * + * Requires a user JWT. An API access token is rejected by the gateway, + * because it identifies a token rather than a person. + * + * @returns a function that disconnects. Call it on unmount. + * + * @example + * ```ts + * const stop = await client.onNotification((event) => { + * if (event.type === 'created') refreshBell(); + * }); + * // later + * stop(); + * ``` + */ + async onNotification( + onEvent: (event: LiveNotificationEvent) => void, + ): Promise<() => void> { + // Resolved per connection rather than reused from construction, so a + // provider-backed client opens the socket with a current token. + const token = await this.resolveAccessToken(); + const socket = io( + `${this.socketOrigin}/notifications?accessToken=${encodeURIComponent(token)}`, + { transports: ['websocket'] }, + ); + + socket.on('notification.created', (data: { id: string }) => + onEvent({ type: 'created', id: data?.id }), + ); + socket.on('notification.read', (data: { id: string }) => + onEvent({ type: 'read', id: data?.id }), + ); + socket.on('notifications.allRead', (data: { batch: number }) => + onEvent({ type: 'allRead', batch: data?.batch }), + ); + + return () => socket.disconnect(); + } } From 4adc76a625cae39900c2e56c8141bc87cc8518b6 Mon Sep 17 00:00:00 2001 From: abujalance Date: Fri, 14 Aug 2026 16:34:15 +0200 Subject: [PATCH 7/7] revert: drop the backend types submodule from this PR This repo is public and the backend is private, so vendoring its DTOs made a clean clone unbuildable and left fork PRs, which get no secrets, with no way to run CI. Reaching outside src also moved tsc's root: the published layout became dist/src/... plus dist/vendor/... with index.d.ts as a stub, so every deep import broke. Both are Sergio's findings and both are hard blockers rather than preferences. The types are declared in src/types/notifications.ts instead, the same as every other type in this package, with a note on why and what sharing them properly would take. Sharing needs the backend's wire DTOs published as their own package so they arrive through node_modules rather than through the source tree. The client methods are unaffected and stay. Also removes the App-token CI step, which had no fallback path anyway: without continue-on-error the job died before the || could be evaluated. --- .changeset/notifications-client.md | 5 +- .github/workflows/ci.yml | 21 ---- .github/workflows/release.yml | 21 ---- .gitmodules | 4 - package.json | 2 - scripts/update-types.sh | 150 ----------------------------- src/types/notifications.ts | 73 ++++++++++---- tsconfig.json | 6 +- vendor/backend-api | 1 - vite.config.mts | 6 +- 10 files changed, 59 insertions(+), 230 deletions(-) delete mode 100644 .gitmodules delete mode 100755 scripts/update-types.sh delete mode 160000 vendor/backend-api diff --git a/.changeset/notifications-client.md b/.changeset/notifications-client.md index b9dfe0f..b3b1d78 100644 --- a/.changeset/notifications-client.md +++ b/.changeset/notifications-client.md @@ -8,6 +8,5 @@ Add notification methods to `PlatformClient`: `getNotifications`, `unsubscribeFromAutomation`. All scoped to the signed-in user via the bearer token an app already has. -The notification types are re-exported from the backend repo rather than -copied into `src/types`, so the contract cannot drift from what the API -sends. Run `yarn types:update` to move the pin. +The notification types mirror the backend's wire DTOs in `src/types`, the +same as every other type here. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16993c1..c9973b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,27 +15,6 @@ jobs: with: node-version: 24 - # The backend repo is private, so the default GITHUB_TOKEN cannot clone - # the submodule. A GitHub App mints a token scoped to that one repo and - # valid for an hour, so nothing long-lived than the App key is stored. - - uses: actions/create-github-app-token@v1 - id: backend-types-token - with: - app-id: ${{ secrets.BACKEND_TYPES_APP_ID }} - private-key: ${{ secrets.BACKEND_TYPES_APP_PRIVATE_KEY }} - owner: ThatOpen - repositories: platform_backend-api - - # Falls back to a plain token or a deploy key if those secrets are set - # instead. The script picks whichever it finds and prints setup - # instructions if it finds none. It also re-applies the sparse checkout, - # which does not survive a fresh clone. - - name: Fetch backend contract types - env: - BACKEND_TYPES_TOKEN: ${{ steps.backend-types-token.outputs.token || secrets.BACKEND_TYPES_TOKEN }} - BACKEND_TYPES_DEPLOY_KEY: ${{ secrets.BACKEND_TYPES_DEPLOY_KEY }} - run: yarn types:init - - run: yarn install --frozen-lockfile - run: yarn build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba1958f..06f2153 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,27 +21,6 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org - # The backend repo is private, so the default GITHUB_TOKEN cannot clone - # the submodule. A GitHub App mints a token scoped to that one repo and - # valid for an hour, so nothing long-lived than the App key is stored. - - uses: actions/create-github-app-token@v1 - id: backend-types-token - with: - app-id: ${{ secrets.BACKEND_TYPES_APP_ID }} - private-key: ${{ secrets.BACKEND_TYPES_APP_PRIVATE_KEY }} - owner: ThatOpen - repositories: platform_backend-api - - # Falls back to a plain token or a deploy key if those secrets are set - # instead. The script picks whichever it finds and prints setup - # instructions if it finds none. It also re-applies the sparse checkout, - # which does not survive a fresh clone. - - name: Fetch backend contract types - env: - BACKEND_TYPES_TOKEN: ${{ steps.backend-types-token.outputs.token || secrets.BACKEND_TYPES_TOKEN }} - BACKEND_TYPES_DEPLOY_KEY: ${{ secrets.BACKEND_TYPES_DEPLOY_KEY }} - run: yarn types:init - # Ensure npm supports OIDC trusted publishing (>= 11.5.1). - run: npm install -g npm@11.5.1 diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index ac4a1a9..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "vendor/backend-api"] - path = vendor/backend-api - url = https://github.com/ThatOpen/platform_backend-api.git - branch = dev diff --git a/package.json b/package.json index 4e771ca..e425fd3 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,6 @@ "scripts": { "dev": "vite", "lint": "eslint src/", - "types:update": "./scripts/update-types.sh", - "types:init": "./scripts/update-types.sh --pin-only", "build": "eslint src/ && tsc && vite build && vite build --config vite.config.cli.mts && node scripts/generate-cli-docs-paths.mjs && node scripts/generate-client-examples-paths.mjs", "build:lib": "tsc && vite build", "test": "vitest run", diff --git a/scripts/update-types.sh b/scripts/update-types.sh deleted file mode 100755 index c4832dc..0000000 --- a/scripts/update-types.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env bash -# Refresh the backend contract types. -# -# The shared DTOs live in the backend repo, so they arrive here as a git -# submodule. Two things this script does that a plain `git submodule update` -# does not: -# -# 1. Applies a sparse checkout, so only `src/common/dto` lands on disk -# instead of the whole backend repository. Sparse config lives in the -# submodule's .git and is not committed, so it has to be re-applied on -# every fresh clone — including in CI. -# 2. Moves the pin to the tip of the tracked branch. Deliberately a command -# you run, not a postinstall hook: an install that silently moved the -# pin would break builds with no commit explaining why. -# -# After running this, `git add vendor/backend-api` and commit the new pin. -set -euo pipefail - -SUBMODULE_PATH="vendor/backend-api" -SPARSE_PATH="src/common/dto" -BACKEND_REPO="ThatOpen/platform_backend-api" -KEY_SECRET="BACKEND_TYPES_DEPLOY_KEY" -APP_ID_SECRET="BACKEND_TYPES_APP_ID" -APP_KEY_SECRET="BACKEND_TYPES_APP_PRIVATE_KEY" -TOKEN_SECRET="BACKEND_TYPES_TOKEN" - -# The backend repo is private. Locally that is fine, git uses whatever -# credentials you already have. In CI there is no such thing, and the failure -# is a bare "Repository not found" that says nothing about what to do, so -# spell it out instead. -token_instructions() { - cat < Repository -> Contents: Read-only (nothing else) - Where installed: Only on this account - - 2. On the App page, note the App ID, then "Generate a private key". - That downloads a .pem file. - - 3. Install App -> ThatOpen -> Only select repositories -> - platform_backend-api - - 4. Add two secrets here: - ${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-}/settings/secrets/actions - ${APP_ID_SECRET} : the App ID from step 2 - ${APP_KEY_SECRET} : the whole .pem, BEGIN and END lines included - - 5. Delete the .pem locally and re-run this job. - - Quicker alternative, a fine-grained token. Expires within a year and is - tied to whoever made it: - - 1. https://github.com/settings/personal-access-tokens/new - Resource owner : ThatOpen - Repository access : Only select repositories -> platform_backend-api - Repository permissions: Contents -> Read-only - 2. Store it as ${TOKEN_SECRET} in this repo's Actions secrets. Because - the owner is the organisation, it may sit in "pending approval" - until an org owner accepts it. - - A read-only deploy key stored as ${KEY_SECRET} also works. - - If one of these is already set, it has most likely been revoked or lost - access to ${BACKEND_REPO}. A token may simply have expired. - -INSTRUCTIONS -} - -fail_with_instructions() { - local headline="$1" - if [ -n "${GITHUB_ACTIONS:-}" ]; then - echo "::error title=Backend types unavailable::${headline} See the log for how to fix it." - token_instructions - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then - { - echo "## Backend contract types could not be fetched" - echo - echo "**${headline}**" - echo '```' - token_instructions - echo '```' - } >>"$GITHUB_STEP_SUMMARY" - fi - else - echo "${headline}" - token_instructions - fi - exit 1 -} - -# Only enforced in CI. A developer's own git credentials already cover the -# private repo, so requiring the token locally would be noise. -if [ -n "${CI:-}" ] && - [ -z "${BACKEND_TYPES_DEPLOY_KEY:-}" ] && - [ -z "${BACKEND_TYPES_TOKEN:-}" ]; then - fail_with_instructions \ - "Neither ${KEY_SECRET} nor ${TOKEN_SECRET} is set." -fi - -# Both rewrite the submodule's https URL rather than changing .gitmodules, so -# a developer cloning over https locally is unaffected. Scoped to this -# process and undone on the way out, so the credential never becomes the -# identity for anything else in the job. -if [ -n "${BACKEND_TYPES_DEPLOY_KEY:-}" ]; then - KEY_FILE=$(mktemp) - printf '%s\n' "$BACKEND_TYPES_DEPLOY_KEY" >"$KEY_FILE" - chmod 600 "$KEY_FILE" - export GIT_SSH_COMMAND="ssh -i $KEY_FILE -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" - git config --global "url.git@github.com:.insteadOf" "https://github.com/" - trap 'rm -f "$KEY_FILE"; git config --global --unset-all "url.git@github.com:.insteadOf" || true' EXIT -elif [ -n "${BACKEND_TYPES_TOKEN:-}" ]; then - git config --global \ - "url.https://x-access-token:${BACKEND_TYPES_TOKEN}@github.com/.insteadOf" \ - "https://github.com/" - trap 'git config --global --unset-all "url.https://x-access-token:${BACKEND_TYPES_TOKEN}@github.com/.insteadOf" || true' EXIT -fi - -if ! git submodule update --init "$SUBMODULE_PATH"; then - fail_with_instructions "Could not clone the backend types submodule." -fi - -git -C "$SUBMODULE_PATH" sparse-checkout init --cone -git -C "$SUBMODULE_PATH" sparse-checkout set "$SPARSE_PATH" - -if [ "${1:-}" = "--pin-only" ]; then - echo "Pinned at $(git -C "$SUBMODULE_PATH" rev-parse --short HEAD) (not moved)." - exit 0 -fi - -if ! git submodule update --remote "$SUBMODULE_PATH"; then - fail_with_instructions "Could not update the backend types submodule." -fi -git -C "$SUBMODULE_PATH" sparse-checkout set "$SPARSE_PATH" - -BRANCH=$(git config -f .gitmodules "submodule.$SUBMODULE_PATH.branch") -echo "Types updated from '$BRANCH' at $(git -C "$SUBMODULE_PATH" rev-parse --short HEAD)." -echo "Commit the new pin with: git add $SUBMODULE_PATH" diff --git a/src/types/notifications.ts b/src/types/notifications.ts index 8571e58..8fd2106 100644 --- a/src/types/notifications.ts +++ b/src/types/notifications.ts @@ -1,23 +1,60 @@ -// Re-exported from the backend rather than redeclared here. +// Declared here rather than imported from the backend. // -// Every other file in this directory is a hand-maintained copy of a backend -// type, which is why `base.ts` has to alias `ObjectId` to `string` and hope -// it stays true. These come straight from `src/common/dto` in the backend -// repo, vendored as a submodule, so the contract cannot drift from what the -// API actually sends. +// The obvious move is to vendor `src/common/dto` from the backend repo so the +// contract has one definition. It does not work for this package: this repo +// is public and the backend is private, so a clean clone and any fork PR +// could not build, and reaching outside `src` moves tsc's root and reshuffles +// the published `dist` layout, breaking deep imports. // -// Run `yarn types:update` to move the pin. Nothing in that directory imports -// `mongodb`, which is what makes it safe to compile in a browser build. -export type { - NotificationCategoryDto, - NotificationDto, - NotificationPageDto, - NotificationTypeDto, - MarkNotificationsReadResultDto, - UnreadCountDto, -} from '../../vendor/backend-api/src/common/dto/notifications.dto'; - -export type { BaseDto } from '../../vendor/backend-api/src/common/dto/base.dto'; +// Sharing these properly means publishing the backend's wire DTOs as their +// own package and depending on it normally. Until then this mirrors +// `src/common/dto/notifications.dto.ts` and has to be updated alongside it. + +export type NotificationCategoryDto = 'automation' | 'invitation'; + +export type NotificationTypeDto = + | 'automation.run.started' + | 'automation.run.finished' + | 'invitation.added' + | 'invitation.accepted' + | 'project.role_changed'; + +/** + * One notification as the API returns it. + * + * Ids are strings and timestamps are ISO-8601 strings, because that is what + * JSON carries. The producer payload is deliberately absent: `title`, `body` + * and `link` are built server-side, so the data behind them is an internal + * detail rather than part of this contract. + */ +export interface NotificationDto { + _id: string; + accountId: string; + type: NotificationTypeDto; + category: NotificationCategoryDto; + title: string; + body: string; + link?: string; + /** Silenced by the recipient: still listed, but no badge and no channel. */ + muted: boolean; + readAt: string | null; + createdAt: string; +} + +/** `nextCursor` is opaque — pass it back verbatim. Null means the last page. */ +export interface NotificationPageDto { + items: NotificationDto[]; + nextCursor: string | null; +} + +/** Excludes muted and already-read notifications: this is the bell badge. */ +export interface UnreadCountDto { + count: number; +} + +export interface MarkNotificationsReadResultDto { + updated: number; +} /** A user's opt-in to one automation's runs, as the API returns it. */ export interface NotificationSubscriptionView { diff --git a/tsconfig.json b/tsconfig.json index 90d26ee..b9ffc4f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,10 +19,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": [ - "src", - "vendor/backend-api/src/common/dto", - "test.local.dev/clientus.ts" - ], + "include": ["src", "test.local.dev/clientus.ts"], "exclude": ["src/cli/templates", "src/built-in/**/example.ts"] } diff --git a/vendor/backend-api b/vendor/backend-api deleted file mode 160000 index e8d1b25..0000000 --- a/vendor/backend-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e8d1b25ab2a35f372fe4fe493ed5312fb9b54f2a diff --git a/vite.config.mts b/vite.config.mts index 8b876f1..ac055c7 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -18,11 +18,7 @@ export default defineConfig({ plugins: [ dts({ insertTypesEntry: true, - // The vendored backend DTOs are re-exported from src/types/notifications, - // so their declarations have to be emitted too. Without this the - // published .d.ts points at ../../vendor/... and resolves to nothing on - // a consumer's machine. - include: ['src', 'vendor/backend-api/src/common/dto'], + include: ['src'], copyDtsFiles: true, }), ],