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
15 changes: 12 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -623,10 +623,15 @@ jobs:
run: |
set -euo pipefail

# Select only, never --create: a fresh stack has no state, so `pulumi
# up` would try to rebuild resources that already exist.
if ! pulumi stack select "$STACK"; then
echo "::error::Pulumi stack '$STACK' was not found. PULUMI_ACCESS_TOKEN must belong to the Pulumi org that owns the openerrata stacks. A deploy must never create a fresh stack: it would try to rebuild live resources."
exit 1
fi

# Stack config is rebuilt from scratch on every run: this step is the
# complete configuration of the hosted stacks.
pulumi stack select "$STACK" --create

if [ -z "${CI_IMAGE_REPOSITORY:-}" ] || [ -z "${CI_IMAGE_TAG:-}" ] || [ -z "${CI_IMAGE_DIGEST:-}" ]; then
echo "Build image metadata is required (CI_IMAGE_REPOSITORY, CI_IMAGE_TAG, CI_IMAGE_DIGEST)."
exit 1
Expand Down Expand Up @@ -817,7 +822,11 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
set -euo pipefail
pulumi stack select "$STACK" --create
# Select only, never --create (see "Configure Pulumi stack values").
if ! pulumi stack select "$STACK"; then
echo "::error::Pulumi stack '$STACK' was not found. PULUMI_ACCESS_TOKEN must belong to the Pulumi org that owns the openerrata stacks. A deploy must never create a fresh stack: it would try to rebuild live resources."
exit 1
fi
TARGET_NAMESPACE="openerrata-${STACK}"
API_SERVICE_NAME="${TARGET_NAMESPACE}-api"
PULUMI_TIMEOUT_SECONDS=1200
Expand Down
14 changes: 12 additions & 2 deletions .github/workflows/release-extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,10 @@ jobs:
timeout-minutes: 20
environment: extension-store-publish
steps:
# Missing credentials fail the release instead of silently skipping a store.
# Each store publishes only when its variable on the extension-store-publish
# environment is "true". An enabled store with missing credentials fails
# the release instead of silently skipping it; with neither enabled, the
# release is the GitHub release's assets alone (manual store upload).
- name: Check store credentials
shell: bash
env:
Expand All @@ -141,10 +144,16 @@ jobs:
CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
FIREFOX_JWT_ISSUER: ${{ secrets.FIREFOX_JWT_ISSUER }}
FIREFOX_JWT_SECRET: ${{ secrets.FIREFOX_JWT_SECRET }}
PUBLISH_TO_CHROME_WEB_STORE: ${{ vars.PUBLISH_TO_CHROME_WEB_STORE }}
PUBLISH_TO_FIREFOX_AMO: ${{ vars.PUBLISH_TO_FIREFOX_AMO }}
run: |
set -euo pipefail
required=(CHROME_EXTENSION_ID CHROME_CLIENT_ID CHROME_CLIENT_SECRET CHROME_REFRESH_TOKEN)
required=()
if [ "${PUBLISH_TO_CHROME_WEB_STORE:-}" = "true" ]; then
required+=(CHROME_EXTENSION_ID CHROME_CLIENT_ID CHROME_CLIENT_SECRET CHROME_REFRESH_TOKEN)
else
echo "::notice::Not publishing to the Chrome Web Store (environment variable PUBLISH_TO_CHROME_WEB_STORE is not true); upload the GitHub release's Chrome zip manually."
fi
if [ "${PUBLISH_TO_FIREFOX_AMO:-}" = "true" ]; then
required+=(FIREFOX_JWT_ISSUER FIREFOX_JWT_SECRET)
else
Expand All @@ -166,6 +175,7 @@ jobs:
path: .

- name: Publish to Chrome Web Store
if: vars.PUBLISH_TO_CHROME_WEB_STORE == 'true'
env:
CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
Expand Down
12 changes: 11 additions & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,22 @@ anonymous identifier from a hash of your IP address range (/24 for IPv4, /48
for IPv6) and User-Agent string. This is used solely for per-day view-credit
rate limiting. Your full IP address and User-Agent are not stored.

### Extension version counts

For each UTC day, the server counts how many page views each extension version
reported (for example, "0.4.0: 1,200 views on 2026-11-02"). Only the day, the
version and the count are stored — no IP address, viewer identifier, post or
time of day — so a count cannot be linked to you or to what you read. The
counts tell us when no one uses an old extension version any more, so the
server can stop supporting it.

## What Data We Do Not Collect

- Email addresses, real names, or account credentials
- Browsing history or activity outside of supported platform pages
- Demographic, location, or device information
- Analytics, telemetry, or crash reports
- Analytics, telemetry, or crash reports (beyond the anonymous per-version
daily counts above)
- Cookies or cross-site tracking identifiers

## How Data Is Used
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,23 @@ After `pnpm dev:ext`, load the built extension as an unpacked extension:
For signed Firefox releases, set `FIREFOX_GECKO_ID=<your-addon-id>` before
building to control the generated `browser_specific_settings.gecko.id`.

### Releasing the Extension

Push a tag `ext-v<version>` (e.g. `ext-v0.4.0`). The Release Extension workflow
tests and packages the extension and creates a GitHub release with the Chrome
zip and `.crx` and the Firefox zip as assets. Store publishing is opt-in per
store, through variables on the `extension-store-publish` environment:

- `PUBLISH_TO_CHROME_WEB_STORE=true` uploads and publishes the Chrome zip; it
needs the secrets `CHROME_EXTENSION_ID`, `CHROME_CLIENT_ID`,
`CHROME_CLIENT_SECRET` and `CHROME_REFRESH_TOKEN`.
- `PUBLISH_TO_FIREFOX_AMO=true` signs and lists the Firefox zip on AMO; it needs
`FIREFOX_JWT_ISSUER` and `FIREFOX_JWT_SECRET`.

An enabled store with a missing secret fails the release. With neither enabled,
the release finishes once the GitHub release exists; upload its assets to the
stores by hand.

## Deployment

The Helm chart at `src/helm/openerrata/` is the single deployment artifact for both on-prem and hosted environments. It does not bundle a database or blob storage — it takes a `DATABASE_URL` and an S3-compatible bucket as config. `values.yaml` documents every value; these are required:
Expand Down Expand Up @@ -236,6 +253,20 @@ Run these in order. Steps 1–2 need admin access and are idempotent; rerun them
5. **Deploy** `staging` (a push to the `staging` branch, or a manual run of the Deploy workflow), check it, then `main`.
6. **Retire the static credentials.** Delete the repository secrets `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` and `KUBE_CONFIG_DATA`, which no workflow reads any more, and run the cleanup commands step 1 printed.

### Legacy extension traffic

Extensions older than 0.4.0 are served through a time-boxed adapter
(`api/src/lib/trpc/legacy-extension-v0`), to be retired on 2026-12-01 or once
14 consecutive days pass with no requests from them, whichever is later. This
query lists the days in the last 14 with legacy (0.2.x/0.3.x) page views; no
rows means the traffic is gone:

```sql
SELECT "day", "version", "pageViewCount" FROM "ExtensionVersionDailyCount"
WHERE "version" ~ '^0\.[23](\.|$)' AND "day" > (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::date - 14
ORDER BY "day", "version";
```

## License

[GNU Affero General Public License v3.0](LICENSE)
39 changes: 34 additions & 5 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,25 @@ model InstanceApiKey {
}
```

### Extension version counts

`recordViewAndGetStatus` counts each page view against the reporting extension
version (`x-openerrata-extension-version`) and the UTC day, and nothing else: no
viewer key, IP range, post or time of day, so a row describes traffic, never a
viewer (see PRIVACY.md). The counts tell when an extension line the API still
serves has gone quiet, which is the retirement condition of the legacy v0
protocol adapter (§3.8.1).

```prisma
model ExtensionVersionDailyCount {
day DateTime @db.Date // UTC day
version String // 1–4 numeric components (CHECK)
pageViewCount Int // > 0 (CHECK); upserted atomically per view

@@id([day, version])
}
```

### Platform metadata

Platform metadata is version-scoped only. Each `PostVersion` can have one
Expand Down Expand Up @@ -1466,10 +1485,12 @@ postRouter.registerObservedVersion
Output: { platform, externalId, versionHash, postVersionId, provenance: ContentProvenance }

// Record a view and return the status of this version's investigation, if any.
// Increments raw viewCount and updates uniqueViewScore. Uses postVersionId from
// registerObservedVersion for a direct primary-key lookup (no content re-derivation);
// rejects unknown post versions. Every variant about an existing investigation
// carries its id, so the client can poll an investigation it did not start.
// Increments raw viewCount, updates uniqueViewScore and counts the view against
// the request's extension version for the UTC day (ExtensionVersionDailyCount).
// Uses postVersionId from registerObservedVersion for a direct primary-key lookup
// (no content re-derivation); rejects unknown post versions. Every variant about
// an existing investigation carries its id, so the client can poll an
// investigation it did not start.
postRouter.recordViewAndGetStatus
Input: { postVersionId }
Output:
Expand Down Expand Up @@ -2056,7 +2077,15 @@ The protocol is not versioned: all contexts ship in one bundle, and a content
script orphaned by an extension update can no longer reach the new background.
API compatibility is versioned separately over HTTP
(`x-openerrata-extension-version`, minimum supported version → upgrade
required).
required). Extensions from 0.2.0 up to 0.4.0 speak the legacy v0 API protocol
(the API of the 0.3 line: no investigation ids or FAILED state from
`recordViewAndGetStatus`, `observedImageUrls` in inputs). The API serves them
through a time-boxed adapter (`api/src/lib/trpc/legacy-extension-v0`) that
converts their requests to the current procedures and the answers back, so the
current procedures carry no legacy branches. It is retired on 2026-12-01 or
once the extension version counts (§3.2) show no 0.3.x traffic for 14
consecutive days, whichever is later; the minimum supported version then
becomes 0.4.0.

Future work: design a dedicated UI/UX flow for fact-checking image-only posts
without relying on text-span highlighting.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- ============================================================================
-- Migration 0026: per-version daily extension page-view counts
-- ============================================================================
--
-- One row per (UTC day, extension version): how many recordViewAndGetStatus
-- calls that version made that day. Nothing else is stored — no viewer key, IP
-- or post (PRIVACY.md). It shows when an extension line the API still serves
-- (the legacy v0 adapter for < 0.4.0) has stopped sending traffic. New table
-- only; there is no data to migrate.

-- CreateTable
CREATE TABLE "ExtensionVersionDailyCount" (
"day" DATE NOT NULL,
"version" TEXT NOT NULL,
"pageViewCount" INTEGER NOT NULL,

CONSTRAINT "ExtensionVersionDailyCount_pkey" PRIMARY KEY ("day","version")
);

-- Versions are what the API's version gate admits: 1–4 dot-separated numeric
-- components. A row exists only once a view has been counted.
ALTER TABLE "ExtensionVersionDailyCount"
ADD CONSTRAINT "ExtensionVersionDailyCount_version_format_check"
CHECK ("version" ~ '^[0-9]{1,5}(\.[0-9]{1,5}){0,3}$'),
ADD CONSTRAINT "ExtensionVersionDailyCount_page_view_count_positive_check"
CHECK ("pageViewCount" > 0);
12 changes: 12 additions & 0 deletions src/typescript/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -652,3 +652,15 @@ model Source {

@@index([claimId])
}

// Page views (recordViewAndGetStatus calls) per extension version per UTC day.
// Deliberately nothing else — no viewer key, IP or post — so a row describes
// traffic, never a person (PRIVACY.md). Read it to tell when an extension line
// the API still serves (legacy-extension-v0) has gone quiet.
model ExtensionVersionDailyCount {
day DateTime @db.Date
version String
pageViewCount Int

@@id([day, version])
}
6 changes: 5 additions & 1 deletion src/typescript/api/src/lib/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import { z } from "zod";
* receive UPGRADE_REQUIRED errors. This is a property of the API code — when
* the API changes in a way that breaks older extensions, bump this constant
* alongside that change.
*
* Versions from here up to (not including) 0.4.0 speak the legacy v0 protocol
* and are served through the time-boxed adapter in
* `$lib/trpc/legacy-extension-v0`; this becomes "0.4.0" when it is retired.
*/
export const MINIMUM_SUPPORTED_EXTENSION_VERSION = "0.4.0";
export const MINIMUM_SUPPORTED_EXTENSION_VERSION = "0.2.0";

const positiveIntegerFromEnv = z.preprocess((value) => {
if (value === undefined || value === null || value === "") return undefined;
Expand Down
20 changes: 20 additions & 0 deletions src/typescript/api/src/lib/services/extension-version-counts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { DbClient } from "$lib/db/client";

/**
* Count one page view (one `recordViewAndGetStatus` call) against today's UTC
* day for the reporting extension version. The count is all that is kept —
* no viewer key, IP, post or time of day (PRIVACY.md) — and it exists to show
* when an extension line the API still serves has stopped sending traffic.
*/
export async function countExtensionVersionPageView(
db: DbClient,
extensionVersion: string,
): Promise<void> {
// One atomic upsert: concurrent first views of the day cannot race on the key.
await db.$executeRaw`
INSERT INTO "ExtensionVersionDailyCount" ("day", "version", "pageViewCount")
VALUES ((CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::date, ${extensionVersion}, 1)
ON CONFLICT ("day", "version")
DO UPDATE SET "pageViewCount" = "ExtensionVersionDailyCount"."pageViewCount" + 1
`;
}
87 changes: 87 additions & 0 deletions src/typescript/api/src/lib/trpc/legacy-extension-v0/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Legacy v0 extension protocol adapter (time-boxed).
*
* Purpose: extensions older than 0.4.0 speak the extension API as it was at
* commit 9ee2cae — strict response schemas without investigation ids, no
* FAILED status from `recordViewAndGetStatus`, and `observedImageUrls` in
* `registerObservedVersion` inputs. Store installs auto-update, but manual
* installs from GitHub Releases may not; this adapter keeps them working
* without a single legacy branch in the current procedures.
*
* Serves: extension versions >= 0.2.0 and < 0.4.0 (the
* `x-openerrata-extension-version` header). 0.4.0 and newer pass through
* untouched; versions below 0.2.0 are refused by the version gate before
* reaching this middleware.
*
* How: it runs after the version gate and before input parsing. For a legacy
* request it parses the raw input with the vendored legacy schema
* (`wire-schemas.ts`), converts it to the current input, runs the current
* procedure, and converts the current output to the legacy one, validated
* against the vendored legacy output schema (`procedures.ts` documents each
* mapping, including the lossy ones). Anything not representable fails loudly.
*
* Retirement: on 2026-12-01, or once the version counts
* (`ExtensionVersionDailyCount`, see the README runbook) show zero 0.3.x
* requests for 14 consecutive days, whichever is later. Then delete this
* directory, its `.concat()` in `routes/post.ts` and its integration tests, and
* raise MINIMUM_SUPPORTED_EXTENSION_VERSION to "0.4.0".
*/

import { initTRPC, TRPCError } from "@trpc/server";
import { isExtensionVersionAtLeast, type ExtensionApiProcedurePath } from "@openerrata/shared";
import { LEGACY_PROCEDURE_ADAPTERS } from "./procedures.js";

/** Oldest version served (the version gate's minimum). */
const OLDEST_LEGACY_EXTENSION_VERSION = "0.2.0";
/** First version that speaks the current protocol. */
export const FIRST_CURRENT_PROTOCOL_EXTENSION_VERSION = "0.4.0";

function speaksLegacyProtocol(extensionVersion: string): boolean {
const atLeastOldest = isExtensionVersionAtLeast(
extensionVersion,
OLDEST_LEGACY_EXTENSION_VERSION,
);
const atLeastCurrent = isExtensionVersionAtLeast(
extensionVersion,
FIRST_CURRENT_PROTOCOL_EXTENSION_VERSION,
);
if (atLeastOldest !== true || atLeastCurrent === null) {
// The version gate admits only well-formed versions at or above its minimum.
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Extension version ${extensionVersion} passed the version gate but predates the legacy protocol floor ${OLDEST_LEGACY_EXTENSION_VERSION}`,
});
}
return !atLeastCurrent;
}

function isExtensionApiProcedurePath(path: string): path is ExtensionApiProcedurePath {
return Object.hasOwn(LEGACY_PROCEDURE_ADAPTERS, path);
}

/**
* Procedure-builder fragment holding the adapter middleware; extension
* procedures `.concat()` it right after the version gate, whose validated
* `extensionVersion` it reads.
*/
export const legacyExtensionV0Adapter = initTRPC
.context<{ extensionVersion: string }>()
.create()
.procedure.use(async ({ ctx, path, getRawInput, next }) => {
if (!speaksLegacyProtocol(ctx.extensionVersion)) {
return next();
}
if (!isExtensionApiProcedurePath(path)) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `No legacy v0 extension adapter for procedure ${path}`,
});
}
const adapter = LEGACY_PROCEDURE_ADAPTERS[path];
const currentRawInput = adapter.toCurrentRawInput(await getRawInput());
const result = await next({ getRawInput: () => Promise.resolve(currentRawInput) });
if (!result.ok) {
return result;
}
return { ...result, data: adapter.toLegacyRawOutput(result.data) };
});
Loading
Loading