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