Skip to content
Open
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
8 changes: 7 additions & 1 deletion apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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"
Original file line number Diff line number Diff line change
@@ -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`);
7 changes: 7 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions apps/api/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
}

Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/query/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand All @@ -676,6 +681,7 @@ export async function getDefaultDoenetmlVersion() {
const defaultDoenetmlVersion = await prisma.doenetmlVersions.findFirstOrThrow(
{
where: { default: true },
omit: { trackingNpmTag: true },
},
);

Expand Down
76 changes: 76 additions & 0 deletions apps/api/src/query/doenetmlVersion.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>(
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 };
}
52 changes: 51 additions & 1 deletion apps/api/src/routes/infoRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import express from "express";
import express, { Request, Response } from "express";
import crypto from "crypto";
import { getAllCategories } from "../categories";
import {
getAllDoenetmlVersions,
Expand All @@ -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();

Expand Down Expand Up @@ -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);
}
},
);
28 changes: 28 additions & 0 deletions apps/api/src/schemas/doenetmlVersionSchemas.ts
Original file line number Diff line number Diff line change
@@ -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.<run>` vs
* `-dev.<timestamp>.<hash>`). SemVer identifiers are limited to `[0-9A-Za-z-]`,
* so the value stays safe to interpolate into the jsDelivr bundle URL
* (`.../@doenet/standalone@<version>/...`) — 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(),
});
Loading