Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: CI

on:
push:
branches: ["**"]
tags: ["v*"]
pull_request:

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22, 24]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.32.1
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run check
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules/
dist/
coverage/
.turbo/
.DS_Store
*.tgz
*.log
.env
.env.*
!.env.example
5 changes: 5 additions & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"useTabs": true,
"experimentalSortImports": {},
"ignorePatterns": ["**/dist/**", "**/node_modules/**", "**/coverage/**", "pnpm-lock.yaml"]
}
8 changes: 8 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import"],
"categories": {
"correctness": "error",
"suspicious": "warn"
}
}
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Black Swamp AI

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
138 changes: 138 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# @blackswampai/emdash-openanalytics

Native EmDash CMS integration for OpenAnalytics, by Black Swamp AI.

This initial scaffold provides configuration and connection validation, encrypted
server-side credential storage through EmDash, and public-site tracker installation.
Embedded analytics UI is planned and is outside this release.

## Installation

Requires EmDash 1.0.1 or later in the 1.x series and Node.js 22.16 or later.
This scaffold has not been published to npm. For local testing, run `pnpm install`
and `pnpm build` in this checkout, then install it from your EmDash site:

```sh
pnpm add /path/to/emdash-openanalytics
```

After an npm release is published, the installation command will be:

```sh
pnpm add @blackswampai/emdash-openanalytics
```

Register the native plugin in `astro.config.mjs`:

```js
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { openAnalytics } from "@blackswampai/emdash-openanalytics";

export default defineConfig({
integrations: [
emdash({
// Include your site's existing database, storage, and other configuration.
plugins: [openAnalytics()],
}),
],
});
```

Native plugins install as trusted site dependencies and require a deployment.
This plugin belongs in `plugins`, and needs the native-only `page:fragments` hook.

Your public layout must render `<EmDashHead page={page} />` inside `<head>`, using
a context created with `createPublicPageContext` from `emdash/page`.
Import `EmDashHead` from `emdash/ui`. Layouts without that insertion point cannot
render the tracker. See [EmDash's page fragment guide](https://docs.emdashcms.com/plugins/creating-native-plugins/page-fragments/).

## OpenAnalytics setup

1. Create a **private read key** in OpenAnalytics for the site you want to track.
Request `site:read` and `analytics:read`. This scaffold uses only `site:read`;
`analytics:read` prepares the key for the future embedded analytics UI.
Older keys may only have `site:read`; site validation cannot verify the extra scope.
2. Configure `EMDASH_ENCRYPTION_KEY` on the EmDash server **before saving a key**.
Follow [EmDash's secrets and key management guide](https://docs.emdashcms.com/deployment/secrets/).
3. Open **Plugins**, then the settings control for this plugin. Save the API URL,
private read key, and tracking switch. Tracking defaults to enabled, but no
tracker appears before a successful connection validation.
4. Validate the saved connection with the authenticated server route below.

The initial scaffold exposes a server route instead of a setup wizard. From the
browser console on your EmDash admin page, while signed in as an administrator:

```js
const response = await fetch("/_emdash/api/plugins/emdash-openanalytics/validate-connection", {
method: "POST",
headers: { "X-EmDash-Request": "1" },
});
console.log(await response.json());
```

The route requires `plugins:manage`. It reads the stored credential on the server;
you do not pass the key in this request. EmDash wraps the plugin's result in its
standard API response envelope. Connection failures contain safe error details.
Successful validation reports site identity, status, and tracker readiness.

Validation saves the returned public installation configuration. Public page
requests use this saved configuration without calling the OpenAnalytics read API.
Changing the API URL or private key stops injection until you validate again.
A failed validation clears the saved connection. Turning tracking off suppresses
injection immediately; turning it on uses the existing valid connection.

## Security

`oa_sk_…` is private and server-only. EmDash's `secret` setting encrypts it at
rest and presents a write-only admin input; the plugin never puts it in public
HTML, validation results, or logs. Keep the EmDash encryption key available and
back it up according to the host's secret management practices.

`oa_pk_…` is the public browser tracking key. It belongs in page HTML. The plugin
uses EmDash's structured external-script fragment, which escapes attributes and
deduplicates the tracker by a stable fragment key.

The API client rejects redirects and uses a five-second timeout. API response
bodies and transport exceptions are not echoed into errors. The administrator
controls the API and tracker origins: configure endpoints you trust. HTTP works
for local self-hosted deployments; use HTTPS for production credentials.

## Self-hosting

The API URL defaults to the officially documented `https://api.getopen.so` and
is editable. A custom base URL may include a deployment path prefix. The plugin
retrieves `tracking_key`, `script_url`, and `collector_url` from `/v1/read/site`;
it has no hard-coded hosted tracker or collector URL.

If any installation field is `null`, the connection can still validate, but no
script is emitted. Configure your OpenAnalytics deployment's `COLLECTOR_BASE_URL`
and a live public tracking key, then validate again. This scaffold does not add
manual tracker URL overrides.

## Current limitations

- No analytics dashboard, custom admin page, automatic refresh, polling, or retries.
- Revalidate after tracker rotation or a collector URL change. Saved installation
metadata has no automatic expiry; private-key revocation is detected on validation.
- Static pages receive the snapshot available when they are rendered. Rebuild
those pages after changing the connection or tracking switch.
- The tracker independently fetches OpenAnalytics's browser configuration. A
suspended site may validate successfully; ingestion follows OpenAnalytics policy.
- Duplicate protection covers EmDash fragments in a placement. Remove any tracker
tag already installed manually in the theme.

## Development

```sh
pnpm install --frozen-lockfile
pnpm check
```

`check` runs typechecking, linting, formatting checks, tests, build, and package
verification. CI runs the same checks. No npm publication is performed.

Source boundaries are the native plugin entry, settings/configuration, the
server-side OpenAnalytics client, saved connection state, and tracker fragments.
See [verified upstream contracts](docs/upstream-contracts.md) for versions and
the native-plugin/security decisions. MIT licensed; no OpenAnalytics source is bundled.
74 changes: 74 additions & 0 deletions docs/upstream-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Verified upstream contracts

Inspected on 2026-09-29 before implementation:

- EmDash [`54209bc9bd0b48e12bdefa8ac971da01ced7990f`](https://github.com/emdash-cms/emdash/tree/54209bc9bd0b48e12bdefa8ac971da01ced7990f),
core package version 1.0.1. Tests and build use the published `emdash@1.0.1`.
- OpenAnalytics [`f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9`](https://github.com/OpenLabs-so/openanalytics/tree/f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9).

## EmDash

The [native-plugin tutorial](https://docs.emdashcms.com/plugins/creating-native-plugins/your-first-native-plugin/)
defines a build-time `PluginDescriptor` and a named runtime `createPlugin` export
returning `definePlugin(...)`. This package provides `openAnalytics()` as the
descriptor factory, registered in `emdash({ plugins: [...] })`. The plugin ID is
unscoped because it occupies one API URL segment; the npm package remains scoped.

[Generated settings](https://docs.emdashcms.com/plugins/creating-native-plugins/react-admin/)
use `admin.settingsSchema` and `ctx.settings.get`. Defaults appear in the form
but are not persisted automatically, so runtime reads apply the same defaults.
`secret` values are encrypted using `EMDASH_ENCRYPTION_KEY`; admin responses only
indicate whether the secret is set. The source implementation is
`packages/core/src/plugins/settings.ts` and the settings API handler is
`packages/core/src/api/handlers/plugin-settings.ts`.

[Page fragments](https://docs.emdashcms.com/plugins/creating-native-plugins/page-fragments/)
require `hooks.page-fragments:register`. Sandboxed plugins are excluded from this
hook. Native `external-script` contributions support `placement`, `src`, `async`,
`attributes`, and `key`; the renderer escapes attributes and deduplicates keys
within each placement. The public theme must render `EmDashHead` with a public
page context. Sources: `plugins/hooks.ts`, `page/fragments.ts`, and the
`EmDashHead.astro` component in `packages/core/src`.

Native plugin routes receive one context combining request data and plugin APIs.
Private routes support existing permissions and method restrictions. EmDash owns
authentication, CSRF checks, and response envelopes. This plugin uses a private
POST route with `plugins:manage`.

[Block Kit](https://docs.emdashcms.com/plugins/creating-plugins/block-kit/)
is supported by native plugins through an admin interaction route, declared page
or widget metadata, and JSON block responses. Native React pages/widgets require
separate descriptor/runtime entries and an admin module. Neither is needed for
this scaffold; Block Kit remains a suitable candidate for PR #2.

Upstream tests use Vitest. The published
`emdash/internal/plugin-test-runtime` exposes the runtime, route dispatcher, and
settings handlers for integration tests; `emdash/page` exposes fragment rendering.
These internal test exports are development-only and do not enter this package's
production code.

## OpenAnalytics

The [CMS/WordPress contract](https://github.com/OpenLabs-so/openanalytics/blob/f7fc9169f32d48e55eb9106bceae9e87b6aa6bb9/docs/wordpress/README.md)
specifies Bearer authentication with a site-bound private read key and the exact
tracker attributes `data-key` and `data-collector`.
`GET /v1/read/site` requires `site:read`. Future analytics reads require
`analytics:read`, which older/default keys do not necessarily carry.

Verified implementation: `apps/api/src/http/read-key.ts`; schema:
`packages/contracts/openapi/openapi.yaml` (`SiteReadContext` and
`SiteInstallContext`). The response includes `site_id`, `slug`, `name`, `status`,
and `install`. Each installation field is required but nullable. Unknown added
fields must be ignored. Statuses currently are `active`, `suspended`, `deleting`,
and `deleted`. No domain field is provided by this endpoint.

The official API default is `https://api.getopen.so`. Script and collector URLs
come from the deployment's `COLLECTOR_BASE_URL`; they are not derived from the
API host. The read-key budget is 60 requests/minute with a burst of 120, which
motivates saving validated installation data instead of reading on every page.

The WordPress guide's 402 billing description predates the current site status
model. The current metadata route deliberately permits suspended sites so
integrations can obtain installation details and show status. Analytics reads
have a separate suspended-site gate. The client still normalizes HTTP 402 for
compatibility, along with 401, 403, 404, 429, and service errors.
62 changes: 62 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"name": "@blackswampai/emdash-openanalytics",
"version": "0.1.0",
"description": "Native EmDash plugin for OpenAnalytics",
"keywords": [
"analytics",
"emdash",
"emdash-plugin",
"openanalytics"
],
"homepage": "https://github.com/BlackSwampAI/emdash-openanalytics#readme",
"bugs": {
"url": "https://github.com/BlackSwampAI/emdash-openanalytics/issues"
},
"license": "MIT",
"author": "Black Swamp AI",
"repository": {
"type": "git",
"url": "git+https://github.com/BlackSwampAI/emdash-openanalytics.git"
},
"files": [
"dist",
"docs"
],
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
}
},
"scripts": {
"build": "tsdown src/index.ts --format esm --dts --clean",
"dev": "tsdown src/index.ts --format esm --dts --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"lint": "oxlint --deny-warnings",
"format": "oxfmt --ignore-path .gitignore",
"format:check": "oxfmt --ignore-path .gitignore --check",
"check": "npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run package:check",
"package:check": "node scripts/check-package.mjs && npm pack --dry-run",
"prepublishOnly": "npm run check"
},
"devDependencies": {
"@types/node": "24.10.1",
"emdash": "1.0.1",
"oxfmt": "0.59.0",
"oxlint": "1.74.0",
"tsdown": "0.20.3",
"typescript": "5.9.3",
"vitest": "4.0.15"
},
"peerDependencies": {
"emdash": ">=1.0.1 <2"
},
"engines": {
"node": ">=22.16"
},
"packageManager": "pnpm@10.32.1"
}
Loading
Loading