diff --git a/apps/api/.env.example b/apps/api/.env.example index 3fe994e2c..e62fefa2e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -47,4 +47,10 @@ MAGIC_LINK_SECRET="mysecret" DISCOURSE_URL="https://community.example.com" DISCOURSE_SSO_SECRET="mysecret" DISCOURSE_API_KEY="mysecret" -DISCOURSE_API_USERNAME="system" \ No newline at end of file +DISCOURSE_API_USERNAME="system" + +#DoenetML version pinning +# Shared secret for POST /api/info/updateTrackedDoenetmlVersion, called by +# DoenetML's publish workflow to pin a tracked version. If unset, that endpoint +# returns 500. Must match DOENET_VERSION_UPDATE_SECRET in the DoenetML repo. +DOENETML_VERSION_UPDATE_SECRET="mysecret" \ No newline at end of file diff --git a/apps/api/prisma/migrations/20260718120000_add_tracking_npm_tag/migration.sql b/apps/api/prisma/migrations/20260718120000_add_tracking_npm_tag/migration.sql new file mode 100644 index 000000000..aa7514453 --- /dev/null +++ b/apps/api/prisma/migrations/20260718120000_add_tracking_npm_tag/migration.sql @@ -0,0 +1,14 @@ +-- Track which npm dist-tag of @doenet/standalone a doenetmlVersions row follows. +-- Lets an external call pin `fullVersion` to a concrete version on publish so the +-- jsDelivr bundle URL is immutable (browser-cacheable) instead of a moving tag. +ALTER TABLE `doenetmlVersions` + ADD COLUMN `trackingNpmTag` VARCHAR(191) NULL; + +-- Backfill the existing tracking channels (idempotent). The `0.6` row stays NULL +-- so it is never auto-updated. `fullVersion` is left as the tag string here and +-- gets pinned to a concrete version by the first publish (or a manual call). +UPDATE `doenetmlVersions` SET `trackingNpmTag` = 'latest' WHERE `displayedVersion` = '0.7'; +UPDATE `doenetmlVersions` SET `trackingNpmTag` = 'dev' WHERE `displayedVersion` = '0.7dev'; + +-- At most one row may track a given tag (MySQL permits multiple NULLs). +CREATE UNIQUE INDEX `doenetmlVersions_trackingNpmTag_key` ON `doenetmlVersions`(`trackingNpmTag`); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e9345b906..ee8d89a64 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -283,6 +283,13 @@ model doenetmlVersions { id Int @id @default(autoincrement()) displayedVersion String @unique fullVersion String + // The npm dist-tag (e.g. "latest" or "dev") of @doenet/standalone that this + // row tracks. When DoenetML publishes a new version to that tag, an external + // call to /api/info/updateTrackedDoenetmlVersion pins `fullVersion` to the + // concrete version so the jsDelivr URL is immutable and browser-cacheable. + // Null for rows pinned to a fixed version that should never auto-update. + // Unique so at most one row tracks a given tag (MySQL allows multiple NULLs). + trackingNpmTag String? @unique default Boolean @default(false) deprecated Boolean @default(false) removed Boolean @default(false) diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 508bbbe77..c51af515c 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -30,11 +30,13 @@ async function seedDoenetMLVersions() { await updateOrCreateDoenetMLVersion({ displayedVersion: "0.7", fullVersion: "latest", + trackingNpmTag: "latest", default: true, }); await updateOrCreateDoenetMLVersion({ displayedVersion: "0.7dev", fullVersion: "dev", + trackingNpmTag: "dev", }); } diff --git a/apps/api/src/query/activity.ts b/apps/api/src/query/activity.ts index 68d1c6398..b4baf6855 100644 --- a/apps/api/src/query/activity.ts +++ b/apps/api/src/query/activity.ts @@ -665,6 +665,11 @@ export async function getAllDoenetmlVersions() { where: { removed: false, }, + // `trackingNpmTag` is internal wiring for version pinning, not part of the + // public version list. + omit: { + trackingNpmTag: true, + }, orderBy: { displayedVersion: "asc", }, @@ -676,6 +681,7 @@ export async function getDefaultDoenetmlVersion() { const defaultDoenetmlVersion = await prisma.doenetmlVersions.findFirstOrThrow( { where: { default: true }, + omit: { trackingNpmTag: true }, }, ); diff --git a/apps/api/src/query/doenetmlVersion.ts b/apps/api/src/query/doenetmlVersion.ts new file mode 100644 index 000000000..bb6c5f502 --- /dev/null +++ b/apps/api/src/query/doenetmlVersion.ts @@ -0,0 +1,76 @@ +import axios from "axios"; +import { StatusCodes } from "http-status-codes"; +import { prisma } from "../model"; +import { InvalidRequestError } from "../utils/error"; +import { doenetmlFullVersionSchema } from "../schemas/doenetmlVersionSchemas"; + +/** + * npm registry endpoint returning just the dist-tags of a package, e.g. + * `{ "latest": "0.7.21", "dev": "0.7.21-dev.343" }`. + */ +const STANDALONE_DIST_TAGS_URL = + "https://registry.npmjs.org/-/package/@doenet/standalone/dist-tags"; + +/** + * Pin the `doenetmlVersions` row that tracks the npm dist-tag `tag` to a + * concrete version, so the jsDelivr bundle URL becomes immutable and + * browser-cacheable instead of a moving tag. + * + * If `version` is omitted, the current version for `tag` is read from the npm + * registry (used for a manual reconcile; the publish workflow always passes the + * exact version it just published, avoiding npm dist-tag propagation lag). + * + * Idempotent: only writes when the version actually changes. + */ +export async function updateTrackedDoenetmlVersion({ + tag, + version, +}: { + tag: string; + version?: string; +}) { + let resolvedVersion = version; + if (resolvedVersion === undefined) { + const { data } = await axios.get>( + STANDALONE_DIST_TAGS_URL, + ); + resolvedVersion = data[tag]; + if (typeof resolvedVersion !== "string" || resolvedVersion.length === 0) { + throw new InvalidRequestError( + `npm has no @doenet/standalone version for dist-tag "${tag}"`, + ); + } + // Validate the registry-supplied version with the same schema as an + // explicit `version` body param, so this fallback path can never write a + // non-URL-safe value into `fullVersion` (which builds the jsDelivr URL). + if (!doenetmlFullVersionSchema.safeParse(resolvedVersion).success) { + throw new InvalidRequestError( + `npm returned an invalid version "${resolvedVersion}" for dist-tag "${tag}"`, + ); + } + } + + const existing = await prisma.doenetmlVersions.findUnique({ + where: { trackingNpmTag: tag }, + }); + if (existing === null) { + throw new InvalidRequestError( + `No doenetmlVersions row tracks npm dist-tag "${tag}"`, + StatusCodes.NOT_FOUND, + ); + } + + const previousVersion = existing.fullVersion; + const changed = previousVersion !== resolvedVersion; + if (changed) { + await prisma.doenetmlVersions.update({ + where: { trackingNpmTag: tag }, + data: { fullVersion: resolvedVersion }, + }); + console.log( + `Pinned DoenetML tag "${tag}" (${existing.displayedVersion}): ${previousVersion} -> ${resolvedVersion}`, + ); + } + + return { tag, previousVersion, version: resolvedVersion, changed }; +} diff --git a/apps/api/src/routes/infoRoutes.ts b/apps/api/src/routes/infoRoutes.ts index 230b9bf40..c71d36683 100644 --- a/apps/api/src/routes/infoRoutes.ts +++ b/apps/api/src/routes/infoRoutes.ts @@ -1,4 +1,5 @@ -import express from "express"; +import express, { Request, Response } from "express"; +import crypto from "crypto"; import { getAllCategories } from "../categories"; import { getAllDoenetmlVersions, @@ -14,6 +15,9 @@ import { contentIdSchema } from "../schemas/contentSchema"; import { getRecentContentSchema } from "../schemas/infoSchemas"; import { getAllLicenses } from "../query/license"; import { getRecentContent } from "../query/recent"; +import { updateTrackedDoenetmlVersion } from "../query/doenetmlVersion"; +import { updateTrackedDoenetmlVersionSchema } from "../schemas/doenetmlVersionSchemas"; +import { handleErrors } from "../errors/routeErrorHandler"; export const infoRouter = express.Router(); @@ -46,3 +50,49 @@ infoRouter.get( "/getRecentContent", queryLoggedIn(getRecentContent, getRecentContentSchema), ); + +function timingSafeEqualStrings(a: string, b: string) { + const aBuf = Buffer.from(a); + const bBuf = Buffer.from(b); + // timingSafeEqual requires equal-length buffers; the length check itself is + // not secret (the shared secret's length is fixed), so an early return is ok. + if (aBuf.length !== bBuf.length) { + return false; + } + return crypto.timingSafeEqual(aBuf, bBuf); +} + +/** + * Machine-to-machine endpoint (no session) used by DoenetML's publish workflow + * to pin a tracked DoenetML version to the concrete version it just published. + * Authenticated with a shared secret bearer token, mirroring the shared-secret + * pattern used for Discourse SSO. Idempotent, so retries are safe. + */ +infoRouter.post( + "/updateTrackedDoenetmlVersion", + async (req: Request, res: Response) => { + const secret = process.env.DOENETML_VERSION_UPDATE_SECRET; + if (!secret) { + console.log("DOENETML_VERSION_UPDATE_SECRET not configured"); + res.status(500).json({ error: "Update secret not configured" }); + return; + } + + const authHeader = req.headers.authorization ?? ""; + const bearer = authHeader.startsWith("Bearer ") + ? authHeader.slice("Bearer ".length) + : ""; + if (!bearer || !timingSafeEqualStrings(bearer, secret)) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + + try { + const params = updateTrackedDoenetmlVersionSchema.parse(req.body); + const result = await updateTrackedDoenetmlVersion(params); + res.send(result); + } catch (e) { + handleErrors(res, e); + } + }, +); diff --git a/apps/api/src/schemas/doenetmlVersionSchemas.ts b/apps/api/src/schemas/doenetmlVersionSchemas.ts new file mode 100644 index 000000000..7a8114d77 --- /dev/null +++ b/apps/api/src/schemas/doenetmlVersionSchemas.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +/** + * A concrete published `@doenet/standalone` version: `major.minor.patch` with an + * optional SemVer prerelease suffix (e.g. `0.7.21`, `0.7.21-dev.343`). We accept + * the full SemVer prerelease grammar rather than a single fixed dev scheme, + * because DoenetML's dev version format has varied (`-dev.` vs + * `-dev..`). SemVer identifiers are limited to `[0-9A-Za-z-]`, + * so the value stays safe to interpolate into the jsDelivr bundle URL + * (`.../@doenet/standalone@/...`) — no slashes, spaces, or extra path + * segments can slip through. + */ +export const doenetmlFullVersionSchema = z + .string() + .regex( + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/, + "Invalid DoenetML version", + ); + +/** + * Body for POST /api/info/updateTrackedDoenetmlVersion. `tag` selects the row + * to pin (by its `trackingNpmTag`); `version` is the concrete version to pin to. + * When `version` is omitted, the endpoint resolves it from the npm registry. + */ +export const updateTrackedDoenetmlVersionSchema = z.object({ + tag: z.enum(["latest", "dev"]), + version: doenetmlFullVersionSchema.optional(), +}); diff --git a/apps/api/src/test/doenetmlVersion.test.ts b/apps/api/src/test/doenetmlVersion.test.ts new file mode 100644 index 000000000..429472a8c --- /dev/null +++ b/apps/api/src/test/doenetmlVersion.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import crypto from "crypto"; +import axios from "axios"; +import { prisma } from "../model"; +import { updateTrackedDoenetmlVersion } from "../query/doenetmlVersion"; +import { updateTrackedDoenetmlVersionSchema } from "../schemas/doenetmlVersionSchemas"; + +vi.mock("axios"); + +// Track rows created during the test so they can be cleaned up, keeping the +// shared seeded "latest"/"dev" rows untouched. +const createdTagIds: number[] = []; + +async function createTrackingRow(tag: string, fullVersion: string) { + const row = await prisma.doenetmlVersions.create({ + data: { + displayedVersion: `test-${crypto.randomUUID()}`, + fullVersion, + trackingNpmTag: tag, + }, + }); + createdTagIds.push(row.id); + return row; +} + +afterEach(async () => { + vi.mocked(axios.get).mockReset(); + if (createdTagIds.length > 0) { + await prisma.doenetmlVersions.deleteMany({ + where: { id: { in: createdTagIds.splice(0) } }, + }); + } +}); + +describe("updateTrackedDoenetmlVersionSchema", () => { + test("accepts release and dev versions across dev-version schemes", () => { + for (const version of [ + "0.7.21", + "0.7.21-dev.343", + // Older DoenetML dev scheme: -dev.. + "0.7.21-dev.20260718120000.abc1234", + ]) { + expect( + updateTrackedDoenetmlVersionSchema.safeParse({ tag: "dev", version }) + .success, + ).toBe(true); + } + }); + + test("allows omitting the version", () => { + expect( + updateTrackedDoenetmlVersionSchema.safeParse({ tag: "latest" }).success, + ).toBe(true); + }); + + test("rejects an unknown tag", () => { + expect( + updateTrackedDoenetmlVersionSchema.safeParse({ + tag: "beta", + version: "0.7.21", + }).success, + ).toBe(false); + }); + + test("rejects malformed / injection-prone versions", () => { + for (const version of [ + "latest", + "0.7", + "0.7.21/../evil", + "0.7.21/style.css", + "0.7.21 ", + "0.7.21-", // empty prerelease + "0.7.21-dev/../x", + ]) { + expect( + updateTrackedDoenetmlVersionSchema.safeParse({ tag: "latest", version }) + .success, + ).toBe(false); + } + }); +}); + +describe("updateTrackedDoenetmlVersion()", () => { + test("pins the tracked row to an explicit version and is idempotent", async () => { + const tag = `test-${crypto.randomUUID()}`; + await createTrackingRow(tag, "0.0.1"); + + const first = await updateTrackedDoenetmlVersion({ + tag, + version: "0.7.21", + }); + expect(first).toMatchObject({ + tag, + previousVersion: "0.0.1", + version: "0.7.21", + changed: true, + }); + + const row = await prisma.doenetmlVersions.findUnique({ + where: { trackingNpmTag: tag }, + }); + expect(row?.fullVersion).toBe("0.7.21"); + + // Re-running with the same version is a no-op. + const second = await updateTrackedDoenetmlVersion({ + tag, + version: "0.7.21", + }); + expect(second.changed).toBe(false); + expect(second.previousVersion).toBe("0.7.21"); + expect(axios.get).not.toHaveBeenCalled(); + }); + + test("resolves the version from npm when none is provided", async () => { + const tag = `test-${crypto.randomUUID()}`; + await createTrackingRow(tag, "0.0.1"); + + vi.mocked(axios.get).mockResolvedValue({ + data: { latest: "1.2.3", dev: "1.2.3-dev.9", [tag]: "0.7.22" }, + }); + + const result = await updateTrackedDoenetmlVersion({ tag }); + expect(axios.get).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ version: "0.7.22", changed: true }); + + const row = await prisma.doenetmlVersions.findUnique({ + where: { trackingNpmTag: tag }, + }); + expect(row?.fullVersion).toBe("0.7.22"); + }); + + test("rejects a malformed version resolved from npm", async () => { + const tag = `test-${crypto.randomUUID()}`; + await createTrackingRow(tag, "0.0.1"); + + vi.mocked(axios.get).mockResolvedValue({ + data: { [tag]: "0.7.21/../evil" }, + }); + + await expect(updateTrackedDoenetmlVersion({ tag })).rejects.toThrow( + /invalid version/, + ); + + // The bad value must not have been written to the tracked row. + const row = await prisma.doenetmlVersions.findUnique({ + where: { trackingNpmTag: tag }, + }); + expect(row?.fullVersion).toBe("0.0.1"); + }); + + test("throws when no row tracks the tag", async () => { + await expect( + updateTrackedDoenetmlVersion({ + tag: `test-missing-${crypto.randomUUID()}`, + version: "0.7.21", + }), + ).rejects.toThrow(); + }); +}); diff --git a/infra/cloudformation/dev3-doenet-taskdef-includes.yml b/infra/cloudformation/dev3-doenet-taskdef-includes.yml index 7383d10f5..66c2de39a 100644 --- a/infra/cloudformation/dev3-doenet-taskdef-includes.yml +++ b/infra/cloudformation/dev3-doenet-taskdef-includes.yml @@ -58,6 +58,11 @@ Secrets: - Name: MAGIC_LINK_SECRET ValueFrom: Fn::Sub: "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${EnvironmentName}/doenet/MagicLinkSecret" + # Shared secret for POST /api/info/updateTrackedDoenetmlVersion (called by + # DoenetML's publish workflow). Create the SSM parameter before deploying. + - Name: DOENETML_VERSION_UPDATE_SECRET + ValueFrom: + Fn::Sub: "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${EnvironmentName}/doenet/DoenetmlVersionUpdateSecret" # - Name: DISCOURSE_SSO_SECRET # ValueFrom: # Fn::Sub: "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${EnvironmentName}/doenet/DiscourseSsoSecret" diff --git a/infra/cloudformation/prod-doenet-taskdef-includes.yml b/infra/cloudformation/prod-doenet-taskdef-includes.yml index 1f766d304..a60b41a13 100644 --- a/infra/cloudformation/prod-doenet-taskdef-includes.yml +++ b/infra/cloudformation/prod-doenet-taskdef-includes.yml @@ -61,6 +61,11 @@ Secrets: - Name: DISCOURSE_SSO_SECRET ValueFrom: Fn::Sub: "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${EnvironmentName}/doenet/DiscourseSsoSecret" + # Shared secret for POST /api/info/updateTrackedDoenetmlVersion (called by + # DoenetML's publish workflow). Create the SSM parameter before deploying. + - Name: DOENETML_VERSION_UPDATE_SECRET + ValueFrom: + Fn::Sub: "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${EnvironmentName}/doenet/DoenetmlVersionUpdateSecret" # - Name: DISCOURSE_API_KEY # ValueFrom: # Fn::Sub: "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${EnvironmentName}/doenet/DiscourseApiKey"