@learnpack/learnpack 5.0.355 → 5.0.357

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/lib/commands/serve.d.ts +0 -17
  2. package/lib/commands/serve.js +159 -129
  3. package/lib/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
  4. package/lib/creatorDist/assets/index-zrPponAn.css +1701 -0
  5. package/lib/creatorDist/index.html +2 -2
  6. package/lib/models/creator.d.ts +1 -1
  7. package/lib/services/ingest/adapters/gcsCourseStorage.d.ts +13 -0
  8. package/lib/services/ingest/adapters/gcsCourseStorage.js +58 -0
  9. package/lib/services/ingest/adapters/rigoUserTokenAuth.d.ts +15 -0
  10. package/lib/services/ingest/adapters/rigoUserTokenAuth.js +46 -0
  11. package/lib/services/ingest/adapters/rigobotPackageRegistry.d.ts +7 -0
  12. package/lib/services/ingest/adapters/rigobotPackageRegistry.js +119 -0
  13. package/lib/services/ingest/core/buildIngestConfig.d.ts +27 -0
  14. package/lib/services/ingest/core/buildIngestConfig.js +40 -0
  15. package/lib/services/ingest/core/buildInitialSyllabus.d.ts +24 -0
  16. package/lib/services/ingest/core/buildInitialSyllabus.js +104 -0
  17. package/lib/services/ingest/core/buildSidebar.d.ts +13 -0
  18. package/lib/services/ingest/core/buildSidebar.js +28 -0
  19. package/lib/services/ingest/core/coursePaths.d.ts +31 -0
  20. package/lib/services/ingest/core/coursePaths.js +50 -0
  21. package/lib/services/ingest/core/ingestCoursePackage.d.ts +52 -0
  22. package/lib/services/ingest/core/ingestCoursePackage.js +217 -0
  23. package/lib/services/ingest/core/normalizeLearnJson.d.ts +25 -0
  24. package/lib/services/ingest/core/normalizeLearnJson.js +29 -0
  25. package/lib/services/ingest/core/pairLessons.d.ts +29 -0
  26. package/lib/services/ingest/core/pairLessons.js +102 -0
  27. package/lib/services/ingest/core/planWrites.d.ts +44 -0
  28. package/lib/services/ingest/core/planWrites.js +59 -0
  29. package/lib/services/ingest/core/resolveSlug.d.ts +53 -0
  30. package/lib/services/ingest/core/resolveSlug.js +93 -0
  31. package/lib/services/ingest/core/types.d.ts +53 -0
  32. package/lib/services/ingest/core/types.js +2 -0
  33. package/lib/services/ingest/core/validatePackage.d.ts +38 -0
  34. package/lib/services/ingest/core/validatePackage.js +97 -0
  35. package/lib/services/ingest/createIngestService.d.ts +34 -0
  36. package/lib/services/ingest/createIngestService.js +38 -0
  37. package/lib/services/ingest/errors.d.ts +22 -0
  38. package/lib/services/ingest/errors.js +34 -0
  39. package/lib/services/ingest/http/errorMapping.d.ts +8 -0
  40. package/lib/services/ingest/http/errorMapping.js +40 -0
  41. package/lib/services/ingest/http/router.d.ts +32 -0
  42. package/lib/services/ingest/http/router.js +124 -0
  43. package/lib/services/ingest/http/zipPackageReader.d.ts +26 -0
  44. package/lib/services/ingest/http/zipPackageReader.js +62 -0
  45. package/lib/services/ingest/ports/courseStorage.d.ts +25 -0
  46. package/lib/services/ingest/ports/courseStorage.js +2 -0
  47. package/lib/services/ingest/ports/packageRegistry.d.ts +58 -0
  48. package/lib/services/ingest/ports/packageRegistry.js +2 -0
  49. package/lib/services/ingest/ports/requestAuthenticator.d.ts +25 -0
  50. package/lib/services/ingest/ports/requestAuthenticator.js +2 -0
  51. package/lib/utils/coursePackage/bucketIo.d.ts +23 -0
  52. package/lib/utils/coursePackage/bucketIo.js +36 -0
  53. package/lib/utils/coursePackage/learnJson.d.ts +26 -0
  54. package/lib/utils/coursePackage/learnJson.js +36 -0
  55. package/lib/utils/coursePackage/sidebar.d.ts +15 -0
  56. package/lib/utils/coursePackage/sidebar.js +26 -0
  57. package/lib/utils/creatorSocket.js +15 -21
  58. package/lib/utils/gcpCredentials.d.ts +18 -0
  59. package/lib/utils/gcpCredentials.js +27 -0
  60. package/lib/utils/rigoActions.d.ts +16 -0
  61. package/lib/utils/rigoActions.js +102 -1
  62. package/lib/utils/socketRegistry.d.ts +46 -0
  63. package/lib/utils/socketRegistry.js +83 -0
  64. package/package.json +1 -1
  65. package/src/commands/serve.ts +162 -114
  66. package/src/creator/README.md +63 -51
  67. package/src/creator/package-lock.json +1188 -8
  68. package/src/creator/package.json +5 -1
  69. package/src/creator/src/App.tsx +120 -54
  70. package/src/creator/src/components/FileUploader.tsx +1 -12
  71. package/src/creator/src/components/NotificationListener.tsx +1 -8
  72. package/src/creator/src/components/syllabus/SyllabusEditor.tsx +101 -36
  73. package/src/creator/src/locales/en.json +8 -0
  74. package/src/creator/src/locales/es.json +8 -0
  75. package/src/creator/src/utils/completionResult.test.ts +144 -0
  76. package/src/creator/src/utils/completionResult.ts +154 -0
  77. package/src/creator/src/utils/constants.ts +17 -4
  78. package/src/creator/src/utils/socket.test.ts +113 -0
  79. package/src/creator/src/utils/socket.ts +60 -37
  80. package/src/creator/src/utils/store.ts +84 -67
  81. package/src/creator/src/utils/useCompletionWatchdog.test.ts +99 -0
  82. package/src/creator/src/utils/useCompletionWatchdog.ts +37 -0
  83. package/src/creator/vitest.config.ts +16 -0
  84. package/src/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
  85. package/src/creatorDist/assets/index-zrPponAn.css +1701 -0
  86. package/src/creatorDist/index.html +2 -2
  87. package/src/models/creator.ts +1 -1
  88. package/src/services/ingest/adapters/gcsCourseStorage.ts +70 -0
  89. package/src/services/ingest/adapters/rigoUserTokenAuth.ts +65 -0
  90. package/src/services/ingest/adapters/rigobotPackageRegistry.ts +161 -0
  91. package/src/services/ingest/core/buildIngestConfig.ts +51 -0
  92. package/src/services/ingest/core/buildInitialSyllabus.ts +122 -0
  93. package/src/services/ingest/core/buildSidebar.ts +32 -0
  94. package/src/services/ingest/core/coursePaths.ts +59 -0
  95. package/src/services/ingest/core/ingestCoursePackage.ts +343 -0
  96. package/src/services/ingest/core/normalizeLearnJson.ts +47 -0
  97. package/src/services/ingest/core/pairLessons.ts +132 -0
  98. package/src/services/ingest/core/planWrites.ts +91 -0
  99. package/src/services/ingest/core/resolveSlug.ts +119 -0
  100. package/src/services/ingest/core/types.ts +57 -0
  101. package/src/services/ingest/core/validatePackage.ts +132 -0
  102. package/src/services/ingest/createIngestService.ts +66 -0
  103. package/src/services/ingest/errors.ts +45 -0
  104. package/src/services/ingest/http/errorMapping.ts +49 -0
  105. package/src/services/ingest/http/router.ts +156 -0
  106. package/src/services/ingest/http/zipPackageReader.ts +81 -0
  107. package/src/services/ingest/ports/courseStorage.ts +25 -0
  108. package/src/services/ingest/ports/packageRegistry.ts +60 -0
  109. package/src/services/ingest/ports/requestAuthenticator.ts +28 -0
  110. package/src/ui/_app/app.js +325 -325
  111. package/src/ui/app.tar.gz +0 -0
  112. package/src/utils/coursePackage/bucketIo.ts +46 -0
  113. package/src/utils/coursePackage/learnJson.ts +37 -0
  114. package/src/utils/coursePackage/sidebar.ts +28 -0
  115. package/src/utils/creatorSocket.ts +16 -22
  116. package/src/utils/gcpCredentials.ts +38 -0
  117. package/src/utils/rigoActions.ts +163 -0
  118. package/src/utils/socketRegistry.ts +85 -0
  119. package/lib/creatorDist/assets/index-CjddKHB_.css +0 -1
  120. package/src/creatorDist/assets/index-CjddKHB_.css +0 -1
@@ -0,0 +1,119 @@
1
+ import { PackageRegistry } from "../ports/packageRegistry"
2
+ import { IngestError } from "../errors"
3
+
4
+ /**
5
+ * Room to spare under Rigobot's 100-character SlugField, so a suffixed
6
+ * suggestion still fits.
7
+ */
8
+ export const MAX_SLUG_LENGTH = 80
9
+
10
+ /** How many alternatives to offer when a slug is taken. */
11
+ const SUGGESTION_COUNT = 3
12
+
13
+ /**
14
+ * The strictest of the slug rules in play, on purpose.
15
+ *
16
+ * This repo's own `slugify` keeps dots, and two things break when one survives:
17
+ * Rigobot stores `package_slug` in a Django SlugField whose validator rejects
18
+ * them (it persists anyway, since the view never calls full_clean), and the
19
+ * published course is served at `{slug}.learn-pack.com`, where a dot adds a
20
+ * subdomain level the wildcard certificate does not cover.
21
+ * @param raw - Candidate slug or title.
22
+ * @returns Lowercase, hyphen-separated, alphanumeric-only slug.
23
+ */
24
+ export function normalizeSlug(raw: string): string {
25
+ return raw
26
+ .normalize("NFD")
27
+ .replace(/[\u0300-\u036F]/g, "")
28
+ .toLowerCase()
29
+ .replace(/[^\da-z]+/g, "-")
30
+ .replace(/^-+|-+$/g, "")
31
+ .slice(0, MAX_SLUG_LENGTH)
32
+ .replace(/-+$/g, "")
33
+ }
34
+
35
+ /**
36
+ * Proposes alternatives for a slug that is taken.
37
+ *
38
+ * Suggested rather than applied: the slug is the public URL and, in practice,
39
+ * permanent — the rename endpoint does not update Rigobot's copy. Auto-suffixing
40
+ * is how a `solid-principles-7` ends up shipped. This mirrors the creator's own
41
+ * publish dialog, which checks availability and lets the human choose.
42
+ * @param base - The slug that was taken.
43
+ * @param registry - Used to check each candidate.
44
+ * @returns Up to three available slugs.
45
+ */
46
+ export async function suggestSlugs(
47
+ base: string,
48
+ registry: PackageRegistry
49
+ ): Promise<string[]> {
50
+ const suggestions: string[] = []
51
+
52
+ for (let suffix = 2; suggestions.length < SUGGESTION_COUNT; suffix++) {
53
+ // Trim the base, not the suffix, so the candidate stays under the cap.
54
+ const tail = `-${suffix}`
55
+ const candidate = `${base.slice(0, MAX_SLUG_LENGTH - tail.length)}${tail}`
56
+
57
+ // eslint-disable-next-line no-await-in-loop
58
+ if (await registry.isSlugAvailable(candidate)) {
59
+ suggestions.push(candidate)
60
+ }
61
+
62
+ if (suffix > SUGGESTION_COUNT + 10) {
63
+ break
64
+ }
65
+ }
66
+
67
+ return suggestions
68
+ }
69
+
70
+ export type ResolveSlugInput = {
71
+ /** Slug the caller asked for, if any. */
72
+ requestedSlug?: string;
73
+ /** Course title, used to derive a slug when none was requested. */
74
+ courseTitle?: string;
75
+ };
76
+
77
+ /**
78
+ * Settles which slug the course will live under.
79
+ *
80
+ * An explicit slug is validated rather than repaired: silently reshaping what
81
+ * somebody typed hands them a different URL than the one they asked for. A
82
+ * derived slug is only a suggestion, which is why the caller is expected to
83
+ * show it.
84
+ *
85
+ * There is no shared default. An earlier version of the exporter fell back to
86
+ * "LearnPack Course" for an untitled course, which slugifies to
87
+ * `learnpack-course` for *every* such course — and since slugs are globally
88
+ * unique, the second one collides with the first.
89
+ * @param input - Requested slug and/or course title.
90
+ * @returns The slug to use.
91
+ * @throws IngestError `INVALID_SLUG` when nothing usable was given.
92
+ */
93
+ export function resolveSlug(input: ResolveSlugInput): string {
94
+ const requested = (input.requestedSlug || "").trim()
95
+
96
+ if (requested) {
97
+ const normalized = normalizeSlug(requested)
98
+
99
+ if (normalized !== requested) {
100
+ throw new IngestError(
101
+ "INVALID_SLUG",
102
+ `"${requested}" is not a usable slug. Did you mean "${normalized}"? Slugs are lowercase letters, digits and hyphens, up to ${MAX_SLUG_LENGTH} characters.`
103
+ )
104
+ }
105
+
106
+ return normalized
107
+ }
108
+
109
+ const derived = normalizeSlug((input.courseTitle || "").trim())
110
+
111
+ if (!derived) {
112
+ throw new IngestError(
113
+ "INVALID_SLUG",
114
+ "The course has no title to derive a slug from; pass one explicitly"
115
+ )
116
+ }
117
+
118
+ return derived
119
+ }
@@ -0,0 +1,57 @@
1
+ import { LessonType } from "../../../utils/packageManifest"
2
+
3
+ /**
4
+ * A lesson entry as the generator plans it, before any content exists.
5
+ *
6
+ * Mirrors `files/lesson_plan.json`. Everything past `id`, `title` and `type` is
7
+ * optional because an earlier schema (ADR-0001 in learnpack-evals) had neither
8
+ * `lesson_focus` nor `concepts_covered`, and packages generated back then are
9
+ * still ingestable.
10
+ */
11
+ export type PlannedLesson = {
12
+ id: string;
13
+ title: string;
14
+ type?: string;
15
+ module_number?: number;
16
+ duration_minutes?: number;
17
+ concepts_covered?: string[];
18
+ lesson_focus?: string | null;
19
+ key_insight?: string | null;
20
+ };
21
+
22
+ /** The generator's `files/lesson_plan.json`. */
23
+ export type LessonPlan = {
24
+ course_title?: string;
25
+ course_duration_minutes?: number;
26
+ language?: string;
27
+ lessons: PlannedLesson[];
28
+ };
29
+
30
+ /**
31
+ * A planned lesson matched to the exercise folder that actually holds it.
32
+ *
33
+ * `exerciseSlug` comes from the package, never from re-slugifying the title:
34
+ * the generator's Python slugify and this repo's TypeScript one disagree (one
35
+ * collapses every non-alphanumeric run to a hyphen, the other keeps dots), and
36
+ * real titles contain typographic apostrophes that make the difference
37
+ * visible. A recomputed slug would key the sidebar to a folder that does not
38
+ * exist, and the lesson would render empty.
39
+ */
40
+ export type IngestLesson = {
41
+ id: string;
42
+ exerciseSlug: string;
43
+ title: string;
44
+ type: LessonType;
45
+ durationMinutes: number;
46
+ /** The generation topic, from `lesson_focus`, falling back to the title. */
47
+ description: string;
48
+ /** Language codes with a README present for this lesson. */
49
+ languages: string[];
50
+ };
51
+
52
+ /** A plan entry that had no content in the package. */
53
+ export type SkippedLesson = {
54
+ id: string;
55
+ title: string;
56
+ reason: "no-content";
57
+ };
@@ -0,0 +1,132 @@
1
+ import { IngestError } from "../errors"
2
+
3
+ /** Path of the lesson plan inside the package, relative to its root. */
4
+ export const LESSON_PLAN_PATH = ".learn/artifacts/files/lesson_plan.json"
5
+
6
+ /** Path of the course manifest inside the package. */
7
+ export const LEARN_JSON_PATH = "learn.json"
8
+
9
+ export type PackageFiles = Record<string, string>;
10
+
11
+ const normalize = (path: string): string => path.replace(/\\/g, "/")
12
+
13
+ /**
14
+ * Rejects a package the bucket consumers could not read correctly.
15
+ *
16
+ * Two of these rules look arbitrary and are not — both come from how
17
+ * `buildConfig` (`utils/configBuilder.ts`) derives a course from whatever files
18
+ * it finds:
19
+ *
20
+ * - It picks the manifest with `files.find(f => f.name.endsWith("learn.json"))`,
21
+ * and GCS lists lexicographically, where `.learn/…` sorts *before*
22
+ * `learn.json`. A second file ending in that name would shadow the real one
23
+ * and the course would be configured from the wrong manifest.
24
+ * - It reads the exercise slug as the segment after `exercises`, so a nested
25
+ * folder by that name would mint phantom lessons.
26
+ *
27
+ * Neither is hypothetical enough to skip: they cost one pass over the entries
28
+ * and fail with a message that names the offending path.
29
+ * @param files - Package entries, keyed by path relative to the package root.
30
+ * @returns Nothing; a valid package simply returns.
31
+ * @throws IngestError `INVALID_PACKAGE` describing the first problem found.
32
+ */
33
+ export function validatePackage(files: PackageFiles): void {
34
+ const paths = Object.keys(files).map(path => normalize(path))
35
+
36
+ if (!paths.includes(LEARN_JSON_PATH)) {
37
+ throw new IngestError(
38
+ "INVALID_PACKAGE",
39
+ `The package has no ${LEARN_JSON_PATH} at its root`
40
+ )
41
+ }
42
+
43
+ if (!paths.includes(LESSON_PLAN_PATH)) {
44
+ throw new IngestError(
45
+ "INVALID_PACKAGE",
46
+ `The package has no ${LESSON_PLAN_PATH}; the syllabus cannot be built without it`
47
+ )
48
+ }
49
+
50
+ const shadowingManifest = paths.find(
51
+ path => path !== LEARN_JSON_PATH && path.endsWith("learn.json")
52
+ )
53
+ if (shadowingManifest) {
54
+ throw new IngestError(
55
+ "INVALID_PACKAGE",
56
+ `"${shadowingManifest}" would shadow the course manifest: no other file may end in learn.json`
57
+ )
58
+ }
59
+
60
+ const nestedExercises = paths.find(path => {
61
+ const segments = path.split("/")
62
+ // lastIndexOf, not indexOf: the root folder legitimately sits at 0, so
63
+ // asking where the *first* one is would always answer 0 and never flag the
64
+ // nested one this rule exists to catch.
65
+ return segments.lastIndexOf("exercises") > 0
66
+ })
67
+ if (nestedExercises) {
68
+ throw new IngestError(
69
+ "INVALID_PACKAGE",
70
+ `"${nestedExercises}" nests a folder named "exercises"; only the package root may have one`
71
+ )
72
+ }
73
+
74
+ const hasLesson = paths.some(path => path.startsWith("exercises/"))
75
+ if (!hasLesson) {
76
+ throw new IngestError(
77
+ "INVALID_PACKAGE",
78
+ "The package has no exercises/ folder, so it holds no lessons"
79
+ )
80
+ }
81
+ }
82
+
83
+ const README_PATTERN = /^readme(?:\.([a-z]{2}))?\.md$/i
84
+
85
+ /**
86
+ * Reads which exercise folders exist and which languages each one has.
87
+ *
88
+ * A folder counts as a lesson only once it holds a README: an exercise with
89
+ * supporting files but no prose is not something the IDE can render.
90
+ * @param files - Package entries.
91
+ * @returns Folder slugs in listing order, and their language codes.
92
+ */
93
+ export function readExercises(files: PackageFiles): {
94
+ slugs: string[];
95
+ languagesBySlug: Record<string, string[]>;
96
+ } {
97
+ const languagesBySlug: Record<string, string[]> = {}
98
+
99
+ for (const rawPath of Object.keys(files)) {
100
+ const path = normalize(rawPath)
101
+ if (!path.startsWith("exercises/")) {
102
+ continue
103
+ }
104
+
105
+ const [, slug, fileName, ...rest] = path.split("/")
106
+ if (!slug || !fileName || rest.length > 0) {
107
+ continue
108
+ }
109
+
110
+ const match = fileName.match(README_PATTERN)
111
+ if (!match) {
112
+ continue
113
+ }
114
+
115
+ // `README.md` with no language segment is English, matching
116
+ // `getReadmeExtension` in utils/creatorUtilities.
117
+ const lang = (match[1] || "en").toLowerCase()
118
+ if (!languagesBySlug[slug]) {
119
+ languagesBySlug[slug] = []
120
+ }
121
+
122
+ if (!languagesBySlug[slug].includes(lang)) {
123
+ languagesBySlug[slug].push(lang)
124
+ }
125
+ }
126
+
127
+ for (const langs of Object.values(languagesBySlug)) {
128
+ langs.sort()
129
+ }
130
+
131
+ return { slugs: Object.keys(languagesBySlug), languagesBySlug }
132
+ }
@@ -0,0 +1,66 @@
1
+ import { Storage } from "@google-cloud/storage"
2
+ import { requireGcpCredentials } from "../../utils/gcpCredentials"
3
+ import { requireGcsBucketName } from "../../utils/gcsBucketName"
4
+ import { CourseStorage } from "./ports/courseStorage"
5
+ import { PackageRegistry } from "./ports/packageRegistry"
6
+ import { RequestAuthenticator } from "./ports/requestAuthenticator"
7
+ import { createGcsCourseStorage } from "./adapters/gcsCourseStorage"
8
+ import { createRigobotPackageRegistry } from "./adapters/rigobotPackageRegistry"
9
+ import { createRigoUserTokenAuth } from "./adapters/rigoUserTokenAuth"
10
+
11
+ export type IngestService = {
12
+ storage: CourseStorage;
13
+ auth: RequestAuthenticator;
14
+ /**
15
+ * Organization every ingested package must end up under.
16
+ *
17
+ * Rigobot attaches one automatically when the owner belongs to exactly one,
18
+ * so the ingest verifies the result rather than assuming it: landing in the
19
+ * wrong organization cannot be undone through the API.
20
+ */
21
+ organization: string;
22
+ /**
23
+ * Registry scoped to the caller's token.
24
+ *
25
+ * Built per request because the package's owner is whoever the token belongs
26
+ * to — a registry fixed at boot would silently file every ingested course
27
+ * under the server's identity.
28
+ */
29
+ registryFor(rigoToken: string): PackageRegistry;
30
+ };
31
+
32
+ export function requireIngestOrganization(): string {
33
+ const organization = (process.env.INGEST_ORGANIZATION || "").trim()
34
+
35
+ if (!organization) {
36
+ throw new Error(
37
+ "INGEST_ORGANIZATION (env) is required: it names the Rigobot organization ingested packages belong to"
38
+ )
39
+ }
40
+
41
+ return organization
42
+ }
43
+
44
+ /**
45
+ * Composition root: the only place in the ingest service that reads env vars.
46
+ *
47
+ * Everything below this line receives its dependencies already built, which is
48
+ * what keeps the ports testable without credentials and lets the whole service
49
+ * move behind a different transport later.
50
+ * @returns The assembled service.
51
+ * @throws Error naming the missing variable when configuration is incomplete.
52
+ */
53
+ export function createIngestServiceFromEnv(): IngestService {
54
+ const credentials = requireGcpCredentials()
55
+ const bucketName = requireGcsBucketName()
56
+ const organization = requireIngestOrganization()
57
+
58
+ const bucket = new Storage({ credentials }).bucket(bucketName)
59
+
60
+ return {
61
+ storage: createGcsCourseStorage(bucket),
62
+ auth: createRigoUserTokenAuth(),
63
+ organization,
64
+ registryFor: createRigobotPackageRegistry,
65
+ }
66
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Typed failures for the ingest service.
3
+ *
4
+ * The HTTP layer has to answer a slug collision with 409, a package owned by
5
+ * someone else with 403, and an unreachable Rigobot with 502 — three outcomes
6
+ * that arrive as the same `AxiosError`. Discriminating them by message
7
+ * substring would break the first time Rigobot rewords an error, so adapters
8
+ * name the failure where they still have the response in hand and everything
9
+ * downstream switches on `code`.
10
+ */
11
+
12
+ export type IngestErrorCode =
13
+ | "INVALID_PACKAGE"
14
+ | "INVALID_SLUG"
15
+ | "SLUG_TAKEN"
16
+ | "SLUG_OWNED_BY_OTHER"
17
+ | "ORGANIZATION_MISMATCH"
18
+ | "INVALID_TOKEN"
19
+ | "REGISTRY_UNAVAILABLE"
20
+ | "STORAGE_FAILURE";
21
+
22
+ export class IngestError extends Error {
23
+ readonly code: IngestErrorCode
24
+
25
+ readonly cause?: unknown
26
+
27
+ constructor(code: IngestErrorCode, message: string, cause?: unknown) {
28
+ super(message)
29
+ this.name = "IngestError"
30
+ this.code = code
31
+ this.cause = cause
32
+ // Restores the prototype chain, which extending a built-in breaks when the
33
+ // TypeScript target is ES5 — without it `instanceof IngestError` is false.
34
+ Object.setPrototypeOf(this, IngestError.prototype)
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Narrows an unknown catch binding to an IngestError.
40
+ * @param error - Value caught from a throwing call.
41
+ * @returns True when the value is an IngestError.
42
+ */
43
+ export function isIngestError(error: unknown): error is IngestError {
44
+ return error instanceof IngestError
45
+ }
@@ -0,0 +1,49 @@
1
+ import {
2
+ ConflictError,
3
+ ForbiddenError,
4
+ InternalServerError,
5
+ OperationalError,
6
+ UnauthorizedError,
7
+ ValidationError,
8
+ } from "../../../utils/errorHandler"
9
+ import { IngestError, IngestErrorCode } from "../errors"
10
+
11
+ type ErrorFactory = (message: string) => OperationalError;
12
+
13
+ /**
14
+ * Which HTTP failure each ingest failure is.
15
+ *
16
+ * The line between 403 and 409 is the one worth reading twice: a **409** means
17
+ * the slug is yours and the caller can act — retry with overwrite, or pick
18
+ * another. A **403** means it is not yours, and no retry will help. Collapsing
19
+ * the two would leave the CLI unable to tell "try again differently" from
20
+ * "stop".
21
+ *
22
+ * `REGISTRY_UNAVAILABLE` would be a 502 in a vacuum. `errorHandler` has no
23
+ * class for that, and adding one to shade a single case is not worth touching a
24
+ * module the rest of the server depends on; the message already says Rigobot
25
+ * was unreachable.
26
+ *
27
+ * Declared as a total Record, not a switch with a default: adding a code to the
28
+ * union without deciding its status then fails the build instead of quietly
29
+ * becoming a 500.
30
+ */
31
+ const BY_CODE: Record<IngestErrorCode, ErrorFactory> = {
32
+ INVALID_PACKAGE: message => new ValidationError(message),
33
+ INVALID_SLUG: message => new ValidationError(message),
34
+ INVALID_TOKEN: message => new UnauthorizedError(message),
35
+ SLUG_OWNED_BY_OTHER: message => new ForbiddenError(message),
36
+ ORGANIZATION_MISMATCH: message => new ForbiddenError(message),
37
+ SLUG_TAKEN: message => new ConflictError(message),
38
+ REGISTRY_UNAVAILABLE: message => new InternalServerError(message),
39
+ STORAGE_FAILURE: message => new InternalServerError(message),
40
+ }
41
+
42
+ /**
43
+ * Translates a domain failure into the HTTP error the global handler renders.
44
+ * @param error - The failure raised by the ingest service.
45
+ * @returns An operational error carrying the right status code.
46
+ */
47
+ export function toHttpError(error: IngestError): OperationalError {
48
+ return BY_CODE[error.code](error.message)
49
+ }
@@ -0,0 +1,156 @@
1
+ import * as express from "express"
2
+ import { Request, Response, Router } from "express"
3
+ import { asyncHandler, ValidationError } from "../../../utils/errorHandler"
4
+ import { createUploadMiddleware } from "../../../utils/misc"
5
+ import {
6
+ createIngestServiceFromEnv,
7
+ IngestService,
8
+ } from "../createIngestService"
9
+ import { ingestCoursePackage } from "../core/ingestCoursePackage"
10
+ import { isIngestError } from "../errors"
11
+ import { toHttpError } from "./errorMapping"
12
+ import { readPackageZip } from "./zipPackageReader"
13
+
14
+ const FILE_FIELD = "file"
15
+
16
+ let cached: IngestService | undefined
17
+
18
+ /**
19
+ * Builds the ingest service once, on the first request that needs it.
20
+ *
21
+ * Deliberately not built at boot. `INGEST_ORGANIZATION` is a new variable that
22
+ * no existing deployment sets, and the composition root throws without it — so
23
+ * constructing it during startup would stop every current server from booting
24
+ * over a capability it does not use. The repo never lets a new capability do
25
+ * that: a missing `RIGOBOT_SYSTEM_TOKEN` skips the descriptions stage, a
26
+ * missing `REDIS_URL` disables history, and unset GitHub credentials fail only
27
+ * the GitHub endpoints.
28
+ *
29
+ * The failure therefore belongs to the request, not the process, and arrives as
30
+ * a 400 naming the variable — the same shape the GitHub endpoints use.
31
+ * @returns The assembled service.
32
+ * @throws ValidationError when configuration is incomplete.
33
+ */
34
+ function getService(): IngestService {
35
+ if (cached) {
36
+ return cached
37
+ }
38
+
39
+ try {
40
+ cached = createIngestServiceFromEnv()
41
+ return cached
42
+ } catch (error) {
43
+ throw new ValidationError(
44
+ `Course ingest is not configured. ${(error as Error).message}`
45
+ )
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Reads a form field as a boolean.
51
+ *
52
+ * Multer parses the text fields alongside the file into `req.body`, where they
53
+ * arrive as strings.
54
+ * @param value - Raw field value.
55
+ * @returns Whether the flag was set.
56
+ */
57
+ const readFlag = (value: unknown): boolean =>
58
+ value === true || value === "true" || value === "1"
59
+
60
+ /**
61
+ * Converts an ingest failure into its HTTP equivalent.
62
+ *
63
+ * Registered on the router rather than inside the handler so every route added
64
+ * later inherits the mapping instead of re-implementing it.
65
+ * @param error - Whatever the handler rejected with.
66
+ * @param _req - Unused.
67
+ * @param _res - Unused.
68
+ * @param next - Passes the translated error to the global handler.
69
+ * @returns Nothing.
70
+ */
71
+ export function ingestErrorTranslator(
72
+ error: unknown,
73
+ _req: Request,
74
+ _res: Response,
75
+ next: express.NextFunction
76
+ ): void {
77
+ next(isIngestError(error) ? toHttpError(error) : error)
78
+ }
79
+
80
+ /**
81
+ * The ingest endpoint.
82
+ *
83
+ * Accepts `multipart/form-data` rather than a raw zip body: multer with memory
84
+ * storage is already wired in this repo, no raw-body middleware is, and the
85
+ * client side of every existing zip upload already builds a FormData with a
86
+ * `file` field. Matching that means the producer has nothing new to learn.
87
+ * @returns A router to mount under a path prefix.
88
+ */
89
+ export function createIngestRouter(): Router {
90
+ const router = express.Router()
91
+ const upload = createUploadMiddleware()
92
+
93
+ router.post(
94
+ "/packages",
95
+ upload.single(FILE_FIELD),
96
+ asyncHandler(async (req: Request, res: Response) => {
97
+ const service = getService()
98
+
99
+ // eslint-disable-next-line
100
+ // @ts-ignore multer augments the request; the Express types do not know
101
+ const uploaded = req.file
102
+
103
+ if (!uploaded) {
104
+ throw new ValidationError(
105
+ `A "${FILE_FIELD}" field holding the course zip is required`
106
+ )
107
+ }
108
+
109
+ // Shape first, credentials second. Authenticating costs a round trip to
110
+ // Rigobot, and spending it on a request that was never well-formed only
111
+ // buys a less accurate answer — a malformed call would come back 401
112
+ // instead of being told what it actually got wrong. Multer has already
113
+ // buffered the upload by this point either way, so nothing is saved by
114
+ // rejecting earlier.
115
+ const identity = await service.auth.authenticate(req.headers)
116
+
117
+ const body = (req.body || {}) as Record<string, unknown>
118
+ const files = await readPackageZip(uploaded.buffer)
119
+
120
+ const report = await ingestCoursePackage(
121
+ {
122
+ files,
123
+ requestedSlug: typeof body.slug === "string" ? body.slug : undefined,
124
+ overwrite: readFlag(body.overwrite),
125
+ },
126
+ {
127
+ storage: service.storage,
128
+ registry: service.registryFor(identity.rigoToken),
129
+ organization: service.organization,
130
+ }
131
+ )
132
+
133
+ // 200 even when lessons were left out. A partial course is a course, and
134
+ // publish already answers this way: the caller gets the result plus what
135
+ // degraded, and decides how loudly to say it.
136
+ res.json({ message: "Course ingested", ...report })
137
+ })
138
+ )
139
+
140
+ // Must come after the routes: Express only reaches an error middleware for
141
+ // failures raised by handlers registered before it.
142
+ router.use(ingestErrorTranslator)
143
+
144
+ return router
145
+ }
146
+
147
+ /**
148
+ * Drops the memoized service.
149
+ *
150
+ * Exposed for tests, which change the environment between cases and need the
151
+ * next request to rebuild rather than reuse.
152
+ * @returns Nothing.
153
+ */
154
+ export function resetIngestServiceCache(): void {
155
+ cached = undefined
156
+ }