diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioRules.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioRules.cs
new file mode 100644
index 0000000..7cee61e
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioRules.cs
@@ -0,0 +1,79 @@
+using System.Text.Json;
+
+namespace ProCoderJi.Api.Features.Portfolio;
+
+///
+/// The portfolio rules shared with the frontend, read from shared/portfolio/rules.json
+/// (copied to Data/portfolio-rules.json at build time).
+///
+public sealed class PortfolioRules
+{
+ private static readonly JsonSerializerOptions RulesJson = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow
+ };
+
+ public required IReadOnlyList RequiredForPublish { get; init; }
+ public required IReadOnlyDictionary MaxItems { get; init; }
+ public required IReadOnlyDictionary MaxLength { get; init; }
+ public required IReadOnlyList MultilineFields { get; init; }
+ public required TextRules Text { get; init; }
+ public required LinkRules Link { get; init; }
+ public required string EmailPattern { get; init; }
+ public required UsernameRules Username { get; init; }
+
+ public sealed record TextRules(string ControlCharacters, string HtmlLike);
+
+ public sealed record LinkRules(
+ string Scheme,
+ string ForbiddenCharacters,
+ string HostPattern,
+ IReadOnlyDictionary HostRestrictions);
+
+ public sealed record UsernameRules(
+ int MinLength,
+ int MaxLength,
+ string Pattern,
+ IReadOnlyList Reserved,
+ IReadOnlyList BlockedWords);
+
+ private sealed record RulesDocument(
+ int Version,
+ List? RequiredForPublish,
+ Dictionary? MaxItems,
+ Dictionary? MaxLength,
+ List? MultilineFields,
+ TextRules? Text,
+ LinkRules? Link,
+ string? EmailPattern,
+ UsernameRules? Username);
+
+ public static PortfolioRules FromJson(string json)
+ {
+ RulesDocument? document;
+ try { document = JsonSerializer.Deserialize(json, RulesJson); }
+ catch (JsonException cause) { throw new InvalidDataException("The shared portfolio rules are not valid JSON.", cause); }
+ if (document is not { Version: 1 } || document.RequiredForPublish is null || document.MaxItems is null
+ || document.MaxLength is null || document.MultilineFields is null || document.Text is null
+ || document.Link is null || document.EmailPattern is null || document.Username is null
+ || document.Username.Reserved is null || document.Username.BlockedWords is null
+ || document.Link.HostRestrictions is null)
+ throw new InvalidDataException("The shared portfolio rules are incomplete or use an unsupported version.");
+ return new PortfolioRules
+ {
+ RequiredForPublish = document.RequiredForPublish,
+ MaxItems = document.MaxItems,
+ MaxLength = document.MaxLength,
+ MultilineFields = document.MultilineFields,
+ Text = document.Text,
+ Link = document.Link,
+ EmailPattern = document.EmailPattern,
+ Username = document.Username
+ };
+ }
+
+ /// Loads the rules copied next to the application.
+ public static PortfolioRules LoadDefault() =>
+ FromJson(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data", "portfolio-rules.json")));
+}
diff --git a/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs
new file mode 100644
index 0000000..1f6219b
--- /dev/null
+++ b/backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs
@@ -0,0 +1,310 @@
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+
+namespace ProCoderJi.Api.Features.Portfolio;
+
+public enum PortfolioMode { Draft, Publish }
+
+/// A problem at a field path such as projects[0].link.
+public readonly record struct PortfolioProblem(string Path, string Code)
+{
+ /// The stable path:code form used by the shared fixtures.
+ public override string ToString() => $"{Path}:{Code}";
+}
+
+///
+/// Trusted portfolio validation. The frontend implements the same rules in
+/// src/features/portfolio/validation.ts, and both run against
+/// shared/portfolio/fixtures.json. Keep the two in step: when a check changes, add a
+/// fixture first. The rules avoid URI parsers and culture-aware string functions on purpose,
+/// because those behave differently in .NET and JavaScript.
+///
+public sealed class PortfolioValidator
+{
+ private static readonly string[] TopLevelFields =
+ ["name", "headline", "about", "skills", "projects", "education", "experience", "achievements", "links"];
+
+ private readonly PortfolioRules rules;
+ private readonly Regex control;
+ private readonly Regex htmlLike;
+ private readonly Regex linkForbidden;
+ private readonly Regex host;
+ private readonly Regex email;
+ private readonly Regex username;
+ private readonly HashSet reserved;
+ private readonly HashSet multiline;
+
+ public PortfolioValidator(PortfolioRules rules)
+ {
+ this.rules = rules;
+ control = Pattern(rules.Text.ControlCharacters);
+ htmlLike = Pattern(rules.Text.HtmlLike);
+ linkForbidden = Pattern(rules.Link.ForbiddenCharacters);
+ host = Anchored(rules.Link.HostPattern);
+ email = Anchored(rules.EmailPattern);
+ username = Anchored(rules.Username.Pattern);
+ reserved = new HashSet(rules.Username.Reserved, StringComparer.Ordinal);
+ multiline = new HashSet(rules.MultilineFields, StringComparer.Ordinal);
+ }
+
+ public static PortfolioValidator CreateDefault() => new(PortfolioRules.LoadDefault());
+
+ // .NET's "$" also matches before a final newline; "\z" does not, so "riya\n" cannot slip through.
+ private static Regex Anchored(string pattern) => new(pattern + "\\z", RegexOptions.CultureInvariant);
+ private static Regex Pattern(string pattern) => new(pattern, RegexOptions.CultureInvariant);
+
+ public IReadOnlyList ValidateUsername(string value) =>
+ ValidateUsername(JsonValue.Create(value));
+
+ /// Checks a username for the public address /u/<username>.
+ public IReadOnlyList ValidateUsername(JsonNode? node)
+ {
+ const string path = "username";
+ var rule = rules.Username;
+ var read = new List();
+ if (!Read(read, path, node, out var value)) return read;
+ if (IsBlank(value)) return [new(path, "required")];
+ if (value.Length < rule.MinLength) return [new(path, "too-short")];
+ if (value.Length > rule.MaxLength) return [new(path, "too-long")];
+ if (!username.IsMatch(value)) return [new(path, "invalid-format")];
+ if (reserved.Contains(value)) return [new(path, "reserved")];
+ var squashed = value.Replace("-", "", StringComparison.Ordinal);
+ if (rule.BlockedWords.Any(word => squashed.Contains(word, StringComparison.Ordinal)))
+ return [new(path, "blocked-word")];
+ return [];
+ }
+
+ ///
+ /// Checks untrusted portfolio data. A draft only has to be well-formed; publishing also needs
+ /// the fields listed in requiredForPublish. An empty result means the data is valid.
+ ///
+ public IReadOnlyList Validate(JsonNode? node, PortfolioMode mode)
+ {
+ var problems = new List();
+ if (node is not JsonObject root) return [new("$", "invalid-type")];
+ if (!CheckUnknownFields(problems, "", root, TopLevelFields)) return problems;
+
+ bool RequiredOnPublish(string key) => mode == PortfolioMode.Publish && rules.RequiredForPublish.Contains(key);
+ CheckText(problems, "name", Get(root, "name"), "name", RequiredOnPublish("name"));
+ CheckText(problems, "headline", Get(root, "headline"), "headline", RequiredOnPublish("headline"));
+ CheckText(problems, "about", Get(root, "about"), "about", false);
+
+ CheckList(problems, "skills", Get(root, "skills"), "skills", (path, item) =>
+ CheckText(problems, path, new(true, item), "skills.item", true));
+
+ CheckList(problems, "projects", Get(root, "projects"), "projects", (path, item) =>
+ CheckObjectItem(problems, path, item, ["title", "link", "technologies", "description"], project =>
+ {
+ CheckText(problems, $"{path}.title", Get(project, "title"), "projects.title", true);
+ CheckLink(problems, $"{path}.link", Get(project, "link"), null);
+ CheckList(problems, $"{path}.technologies", Get(project, "technologies"), "projects.technologies", (techPath, tech) =>
+ CheckText(problems, techPath, new(true, tech), "projects.technologies.item", true));
+ CheckText(problems, $"{path}.description", Get(project, "description"), "projects.description", false);
+ }));
+
+ CheckList(problems, "education", Get(root, "education"), "education", (path, item) =>
+ CheckObjectItem(problems, path, item, ["institution", "degree", "period", "details"], entry =>
+ {
+ CheckText(problems, $"{path}.institution", Get(entry, "institution"), "education.institution", true);
+ CheckText(problems, $"{path}.degree", Get(entry, "degree"), "education.degree", false);
+ CheckText(problems, $"{path}.period", Get(entry, "period"), "education.period", false);
+ CheckText(problems, $"{path}.details", Get(entry, "details"), "education.details", false);
+ }));
+
+ CheckList(problems, "experience", Get(root, "experience"), "experience", (path, item) =>
+ CheckObjectItem(problems, path, item, ["organization", "role", "period", "description"], entry =>
+ {
+ CheckText(problems, $"{path}.organization", Get(entry, "organization"), "experience.organization", true);
+ CheckText(problems, $"{path}.role", Get(entry, "role"), "experience.role", true);
+ CheckText(problems, $"{path}.period", Get(entry, "period"), "experience.period", false);
+ CheckText(problems, $"{path}.description", Get(entry, "description"), "experience.description", false);
+ }));
+
+ CheckList(problems, "achievements", Get(root, "achievements"), "achievements", (path, item) =>
+ CheckText(problems, path, new(true, item), "achievements.item", true));
+
+ var links = Get(root, "links");
+ if (links.Present)
+ {
+ if (links.Node is not JsonObject linkObject) problems.Add(new("links", "invalid-type"));
+ else if (CheckUnknownFields(problems, "links", linkObject, ["github", "linkedin", "website", "email"]))
+ {
+ CheckLink(problems, "links.github", Get(linkObject, "github"), "links.github");
+ CheckLink(problems, "links.linkedin", Get(linkObject, "linkedin"), "links.linkedin");
+ CheckLink(problems, "links.website", Get(linkObject, "website"), null);
+ CheckEmail(problems, "links.email", Get(linkObject, "email"));
+ }
+ }
+ return problems;
+ }
+
+ private readonly record struct Field(bool Present, JsonNode? Node);
+
+ private static Field Get(JsonObject source, string key) =>
+ source.TryGetPropertyValue(key, out var node) ? new(true, node) : new(false, null);
+
+ private enum StringRead { Ok, NotAString, Malformed }
+
+ ///
+ /// Reads a JSON string without throwing. Depending on the runtime, a lazily decoded string
+ /// that holds a lone surrogate throws while it is read; that and a programmatically built
+ /// string with a lone surrogate are both reported as malformed.
+ ///
+ private static StringRead TryString(JsonNode? node, out string value)
+ {
+ value = "";
+ if (node is not JsonValue json) return StringRead.NotAString;
+ string? text;
+ try
+ {
+ if (!json.TryGetValue(out text) || text is null) return StringRead.NotAString;
+ }
+ catch (Exception cause) when (cause is InvalidOperationException or ArgumentException or FormatException)
+ {
+ return StringRead.Malformed;
+ }
+ if (!IsWellFormed(text)) return StringRead.Malformed;
+ value = text;
+ return StringRead.Ok;
+ }
+
+ /// Adds the problem for an unreadable value; returns true when the value was read.
+ private static bool Read(List problems, string path, JsonNode? node, out string value)
+ {
+ switch (TryString(node, out value))
+ {
+ case StringRead.Ok:
+ return true;
+ case StringRead.Malformed:
+ problems.Add(new(path, "invalid-characters"));
+ return false;
+ default:
+ problems.Add(new(path, "invalid-type"));
+ return false;
+ }
+ }
+
+ /// False when the text has a lone surrogate half, which no encoding can represent.
+ private static bool IsWellFormed(string text)
+ {
+ for (var i = 0; i < text.Length; i++)
+ {
+ if (char.IsHighSurrogate(text[i]))
+ {
+ if (i + 1 >= text.Length || !char.IsLowSurrogate(text[i + 1])) return false;
+ i++;
+ }
+ else if (char.IsLowSurrogate(text[i])) return false;
+ }
+ return true;
+ }
+
+ private static bool IsBlank(string value) => value.All(c => c is ' ' or '\t' or '\r' or '\n');
+
+ /// Lower-cases A-Z only, so other characters such as the Kelvin sign are never folded.
+ private static string AsciiLower(string value) =>
+ string.Create(value.Length, value, static (span, source) =>
+ {
+ for (var i = 0; i < source.Length; i++)
+ span[i] = source[i] is >= 'A' and <= 'Z' ? (char)(source[i] + 32) : source[i];
+ });
+
+ private int Limit(IReadOnlyDictionary table, string key) =>
+ table.TryGetValue(key, out var value) ? value : throw new InvalidDataException($"Missing portfolio rule: {key}");
+
+ ///
+ /// Reports unknown field names. Returns false, after reporting invalid-characters for the
+ /// object itself, when a field name is not well-formed text; the caller then skips that object.
+ /// System.Text.Json decodes property names lazily, so a lone surrogate escape in a parsed name
+ /// throws the first time the object is read.
+ ///
+ private static bool CheckUnknownFields(List problems, string path, JsonObject source, IReadOnlyCollection allowed)
+ {
+ List keys;
+ try { keys = source.Select(property => property.Key).ToList(); }
+ catch (Exception cause) when (cause is InvalidOperationException or ArgumentException or FormatException)
+ {
+ problems.Add(new(path.Length == 0 ? "$" : path, "invalid-characters"));
+ return false;
+ }
+ if (keys.Any(key => !IsWellFormed(key)))
+ {
+ problems.Add(new(path.Length == 0 ? "$" : path, "invalid-characters"));
+ return false;
+ }
+ foreach (var key in keys)
+ if (!allowed.Contains(key)) problems.Add(new(path.Length == 0 ? key : $"{path}.{key}", "unknown-field"));
+ return true;
+ }
+
+ private void CheckText(List problems, string path, Field field, string key, bool required)
+ {
+ if (!field.Present)
+ {
+ if (required) problems.Add(new(path, "required"));
+ return;
+ }
+ if (!Read(problems, path, field.Node, out var value)) return;
+ // Length comes before the blank check, so a field of only spaces cannot exceed its limit.
+ if (value.Length > Limit(rules.MaxLength, key)) { problems.Add(new(path, "too-long")); return; }
+ if (IsBlank(value))
+ {
+ if (required) problems.Add(new(path, "required"));
+ return;
+ }
+ if (control.IsMatch(value) || (!multiline.Contains(key) && value.AsSpan().IndexOfAny('\r', '\n') >= 0))
+ {
+ problems.Add(new(path, "invalid-characters"));
+ return;
+ }
+ if (htmlLike.IsMatch(value)) problems.Add(new(path, "html-like"));
+ }
+
+ /// An optional https link. selects the host restriction, such as links.github.
+ private void CheckLink(List problems, string path, Field field, string? hostKey)
+ {
+ if (!field.Present) return;
+ if (!Read(problems, path, field.Node, out var value)) return;
+ if (value.Length > Limit(rules.MaxLength, "link")) { problems.Add(new(path, "too-long")); return; }
+ if (IsBlank(value)) return;
+ var scheme = rules.Link.Scheme;
+ if (linkForbidden.IsMatch(value) || value.Length < scheme.Length || AsciiLower(value[..scheme.Length]) != scheme)
+ {
+ problems.Add(new(path, "invalid-link"));
+ return;
+ }
+ var rest = value[scheme.Length..];
+ var end = rest.AsSpan().IndexOfAny('/', '?', '#');
+ var authority = end < 0 ? rest : rest[..end];
+ // The host pattern allows only letters, digits, hyphens and dots, so credentials
+ // (user@host), ports (host:8443), IP literals and empty hosts all fail it.
+ var hostName = AsciiLower(authority);
+ if (!host.IsMatch(hostName)) { problems.Add(new(path, "invalid-link")); return; }
+ if (hostKey is not null && rules.Link.HostRestrictions.TryGetValue(hostKey, out var allowed)
+ && !allowed.Any(domain => hostName == domain || hostName.EndsWith("." + domain, StringComparison.Ordinal)))
+ problems.Add(new(path, "invalid-host"));
+ }
+
+ private void CheckEmail(List problems, string path, Field field)
+ {
+ if (!field.Present) return;
+ if (!Read(problems, path, field.Node, out var value)) return;
+ if (value.Length > Limit(rules.MaxLength, "email")) { problems.Add(new(path, "too-long")); return; }
+ if (IsBlank(value)) return;
+ if (!email.IsMatch(value)) problems.Add(new(path, "invalid-email"));
+ }
+
+ private void CheckList(List problems, string path, Field field, string key, Action checkItem)
+ {
+ if (!field.Present) return;
+ if (field.Node is not JsonArray array) { problems.Add(new(path, "invalid-type")); return; }
+ if (array.Count > Limit(rules.MaxItems, key)) { problems.Add(new(path, "too-many")); return; }
+ for (var i = 0; i < array.Count; i++) checkItem($"{path}[{i}]", array[i]);
+ }
+
+ private static void CheckObjectItem(List problems, string path, JsonNode? item, string[] allowed, Action checkFields)
+ {
+ if (item is not JsonObject source) { problems.Add(new(path, "invalid-type")); return; }
+ if (CheckUnknownFields(problems, path, source, allowed)) checkFields(source);
+ }
+}
diff --git a/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj b/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj
index e254ada..1ef4f32 100644
--- a/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj
+++ b/backend/src/ProCoderJi.Api/ProCoderJi.Api.csproj
@@ -21,6 +21,7 @@
+
diff --git a/backend/tests/ProCoderJi.Api.Tests/PortfolioValidatorTests.cs b/backend/tests/ProCoderJi.Api.Tests/PortfolioValidatorTests.cs
new file mode 100644
index 0000000..37a1e4c
--- /dev/null
+++ b/backend/tests/ProCoderJi.Api.Tests/PortfolioValidatorTests.cs
@@ -0,0 +1,165 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using ProCoderJi.Api.Features.Portfolio;
+using Xunit;
+
+namespace ProCoderJi.Api.Tests;
+
+///
+/// Runs the shared fixtures that the frontend also runs (src/features/portfolio/portfolio.test.ts).
+/// Add a fixture to shared/portfolio/fixtures.json first when a rule changes.
+///
+public sealed class PortfolioValidatorTests
+{
+ private static readonly PortfolioValidator Validator = PortfolioValidator.CreateDefault();
+
+ private static JsonNode Fixtures => JsonNode.Parse(
+ File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data", "portfolio-fixtures.json")))!;
+
+ private static string[] Sorted(IEnumerable problems) =>
+ problems.Select(problem => problem.ToString()).Order(StringComparer.Ordinal).ToArray();
+
+ private static string[] Expected(JsonNode fixture) =>
+ fixture["problems"]!.AsArray().Select(problem => problem!.GetValue()).Order(StringComparer.Ordinal).ToArray();
+
+ [Fact]
+ public void EverySharedUsernameFixtureGivesTheExpectedProblems()
+ {
+ var cases = Fixtures["usernames"]!.AsArray();
+ Assert.NotEmpty(cases);
+ foreach (var fixture in cases)
+ Assert.True(
+ Expected(fixture!).SequenceEqual(Sorted(Validator.ValidateUsername(fixture!["value"]))),
+ $"{fixture["name"]}: expected [{string.Join(", ", Expected(fixture))}] but got [{string.Join(", ", Sorted(Validator.ValidateUsername(fixture["value"])))}]");
+ }
+
+ [Fact]
+ public void EverySharedPortfolioFixtureGivesTheExpectedProblems()
+ {
+ var cases = Fixtures["portfolios"]!.AsArray();
+ Assert.NotEmpty(cases);
+ foreach (var fixture in cases)
+ {
+ var mode = fixture!["mode"]!.GetValue() switch
+ {
+ "draft" => PortfolioMode.Draft,
+ "publish" => PortfolioMode.Publish,
+ var other => throw new InvalidDataException($"Unknown fixture mode: {other}")
+ };
+ var actual = Sorted(Validator.Validate(fixture["value"], mode));
+ Assert.True(
+ Expected(fixture).SequenceEqual(actual),
+ $"{fixture["name"]}: expected [{string.Join(", ", Expected(fixture))}] but got [{string.Join(", ", actual)}]");
+ }
+ }
+
+ // Text with a lone surrogate cannot be stored in JSON that System.Text.Json parses, so these
+ // fixtures list the UTF-16 code units and each runtime builds the string itself.
+ [Fact]
+ public void EveryMalformedTextFixtureGivesTheExpectedProblems()
+ {
+ var cases = Fixtures["malformedText"]!.AsArray();
+ Assert.NotEmpty(cases);
+ foreach (var fixture in cases)
+ {
+ var text = new string(fixture!["codeUnits"]!.AsArray().Select(unit => (char)unit!.GetValue()).ToArray());
+ var field = fixture["field"]!.GetValue();
+ var actual = Sorted(field == "username"
+ ? Validator.ValidateUsername(text)
+ : Validator.Validate(PortfolioWith(field, text), PortfolioMode.Draft));
+ Assert.True(
+ Expected(fixture).SequenceEqual(actual),
+ $"{fixture["name"]}: expected [{string.Join(", ", Expected(fixture))}] but got [{string.Join(", ", actual)}]");
+ }
+ }
+
+ private static JsonNode PortfolioWith(string field, string text) => field switch
+ {
+ "name" => new JsonObject { ["name"] = text },
+ "about" => new JsonObject { ["about"] = text },
+ "skill" => new JsonObject { ["skills"] = new JsonArray(text) },
+ "projectLink" => new JsonObject { ["projects"] = new JsonArray(new JsonObject { ["title"] = "App", ["link"] = text }) },
+ "website" => new JsonObject { ["links"] = new JsonObject { ["website"] = text } },
+ "email" => new JsonObject { ["links"] = new JsonObject { ["email"] = text } },
+ "key" => new JsonObject { [text] = 1 },
+ "linksKey" => new JsonObject { ["links"] = new JsonObject { [text] = 1 } },
+ "projectKey" => new JsonObject { ["projects"] = new JsonArray(new JsonObject { ["title"] = "App", [text] = 1 }) },
+ _ => throw new InvalidDataException($"Unknown fixture field: {field}")
+ };
+
+ [Fact]
+ public void ALoneSurrogateEscapeInParsedJsonIsAProblemNotAnException()
+ {
+ // JsonNode.Parse accepts the text; System.Text.Json only fails later, when a string or a
+ // property name is read, so the validator has to handle the read failing.
+ var json = "{\"name\":\"" + Escape("d800") + "\",\"skills\":[\"" + Escape("dc00") + "\"]}";
+ Assert.Equal(
+ ["name:invalid-characters", "skills[0]:invalid-characters"],
+ Sorted(Validator.Validate(JsonNode.Parse(json), PortfolioMode.Draft)));
+ }
+
+ [Theory]
+ [InlineData("{\"{0}\":1}", "$:invalid-characters")]
+ [InlineData("{\"links\":{\"{0}\":1}}", "links:invalid-characters")]
+ [InlineData("{\"projects\":[{\"title\":\"App\",\"{0}\":1}]}", "projects[0]:invalid-characters")]
+ public void ALoneSurrogateEscapeInAParsedFieldNameIsAProblemNotAnException(string template, string expected)
+ {
+ var node = JsonNode.Parse(template.Replace("{0}", Escape("d800"), StringComparison.Ordinal));
+ Assert.Equal([expected], Sorted(Validator.Validate(node, PortfolioMode.Draft)));
+ }
+
+ // The backslash is built from a char code so this source never contains a surrogate escape.
+ private static string Escape(string hex) => (char)92 + "u" + hex;
+
+ [Fact]
+ public void ValidatingDoesNotModifyTheInput()
+ {
+ var input = JsonNode.Parse("""{"name":"Riya","skills":["a"],"links":{"github":"https://github.com/riya"}}""")!;
+ var before = input.ToJsonString();
+ Validator.Validate(input, PortfolioMode.Publish);
+ Assert.Equal(before, input.ToJsonString());
+ }
+
+ [Fact]
+ public void ValidUsernamesAndPortfoliosReportNothing()
+ {
+ Assert.Empty(Validator.ValidateUsername("riya-sharma"));
+ Assert.Empty(Validator.Validate(JsonNode.Parse("""{"name":"Riya","headline":"Student"}"""), PortfolioMode.Publish));
+ }
+
+ [Fact]
+ public void ReservedNamesCoverTheNamesListedInTheDecisionRecord()
+ {
+ foreach (var name in new[] { "www", "api", "app", "admin", "mail", "docs", "blog", "login", "support", "help", "procoderji", "satyam", "learn", "practice", "account", "dashboard", "games", "guides", "jobs" })
+ Assert.Contains(name, PortfolioRulesForTest().Username.Reserved);
+ }
+
+ [Fact]
+ public void IncompleteRulesAreRejectedAtStartup()
+ {
+ Assert.Throws(() => PortfolioRules.FromJson("{}"));
+ Assert.Throws(() => PortfolioRules.FromJson("""{"version":2}"""));
+ Assert.Throws(() => PortfolioRules.FromJson("not json"));
+ }
+
+ [Fact]
+ public void AMissingRuleKeyFailsLoudlyInsteadOfSkippingTheCheck()
+ {
+ var rules = JsonNode.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Data", "portfolio-rules.json")))!.AsObject();
+ rules["maxLength"]!.AsObject().Remove("name");
+ var validator = new PortfolioValidator(PortfolioRules.FromJson(rules.ToJsonString()));
+ Assert.Throws(() => validator.Validate(JsonNode.Parse("""{"name":"Riya"}"""), PortfolioMode.Draft));
+ }
+
+ [Fact]
+ public void AHugeValueIsRejectedQuickly()
+ {
+ var started = DateTime.UtcNow;
+ var huge = JsonNode.Parse(JsonSerializer.Serialize(new { links = new { github = "https://" + new string('a', 2_000_000) } }));
+ Assert.Equal(["links.github:too-long"], Sorted(Validator.Validate(huge, PortfolioMode.Draft)));
+ Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(2));
+ }
+
+ private static PortfolioRules PortfolioRulesForTest() =>
+ PortfolioRules.LoadDefault();
+}
diff --git a/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj b/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj
index c3ef368..9fef073 100644
--- a/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj
+++ b/backend/tests/ProCoderJi.Api.Tests/ProCoderJi.Api.Tests.csproj
@@ -20,6 +20,10 @@
+
+
+
+
diff --git a/docs/development.md b/docs/development.md
index 4649b95..fd82fd3 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -102,6 +102,9 @@ by Git.
synchronization.
- `shared/practice/questions.json` is the canonical question bank consumed by the frontend and
backend. Keep IDs stable when improving wording; use a new ID when a question's meaning changes.
+- `shared/portfolio/` holds the student-portfolio rules and test fixtures used by both runtimes
+ (see its [README](../shared/portfolio/README.md)); the validators live in
+ `src/features/portfolio/` and `backend/src/ProCoderJi.Api/Features/Portfolio/`.
- `backend/src/ProCoderJi.Api/` owns Google sign-in, cookie/antiforgery handling, account progress,
and EF persistence. Backend tests live under `backend/tests/`.
- `src/content/` and `src/data/` publish reviewed notes, guides, and listings; see the
diff --git a/shared/portfolio/README.md b/shared/portfolio/README.md
new file mode 100644
index 0000000..e99b0fc
--- /dev/null
+++ b/shared/portfolio/README.md
@@ -0,0 +1,101 @@
+# Portfolio rules
+
+The rules for student portfolios, shared by the frontend and the backend. The decisions behind
+them are in [ADR 0002](../../docs/decisions/0002-student-portfolios.md).
+
+| File | Purpose |
+| --- | --- |
+| `rules.json` | Limits, reserved names, blocked words, and the patterns for links, hosts and email. The only place these numbers live. |
+| `fixtures.json` | Example usernames and portfolios with the problems each must produce. Both validators run all of them. |
+
+The TypeScript validator is `src/features/portfolio/validation.ts`; the .NET validator is
+`backend/src/ProCoderJi.Api/Features/Portfolio/PortfolioValidator.cs`. The TypeScript validator
+gives quick feedback in the editor; the backend is the trusted check before anything is stored.
+
+## What is checked
+
+A portfolio has fixed fields: `name`, `headline`, `about`, `skills`, `projects`, `education`,
+`experience`, `achievements`, and `links` (`github`, `linkedin`, `website`, `email`). Anything else is
+rejected as an `unknown-field`.
+
+- **Modes.** A *draft* only has to be well-formed, so a student can save half-finished work. *Publish* also
+ requires `name` and `headline`. Entries inside a list (a project's title, an
+ experience's role, a skill) are required whenever the entry exists.
+- **Text.** No control or invisible characters (such as zero-width spaces or right-to-left overrides). Only
+ `about`, `projects.description`, `education.details`, `experience.description` may contain line breaks. Text that looks like an HTML tag
+ (a `<` directly followed by a letter, `/`, `!` or `?`) is rejected, so write `x < y` with spaces.
+- **Links.** `https` only, no credentials, no port, no IP address, and no `localhost`. `links.github` must be on
+ `github.com` and `links.linkedin` on `linkedin.com` (subdomains are fine). The `email` field holds an
+ address such as `riya@example.com`; the page adds the `mailto:` link.
+- **Username.** 3 to 30 characters: lowercase letters and digits, with single hyphens between
+ them. Reserved names and names containing a blocked word are refused. Hyphens are ignored when looking for a
+ blocked word, so `pay-tm` is refused too. This is deliberately strict: a harmless name such as `bank-guide` is
+ refused as well.
+
+Each problem is reported as a path and a code, written `path:code`, for example `projects[0].link:invalid-link`.
+The codes are `required`, `invalid-type`, `unknown-field`, `too-short`, `too-long`, `too-many`,
+`invalid-characters`, `html-like`, `invalid-format`, `invalid-link`, `invalid-host`, `invalid-email`,
+`reserved` and `blocked-word`. Only the first failing check is reported for each field.
+
+Checks run in a fixed order for each field: type, well-formed text, length, blank, then the content rules. The length
+check comes before the blank check, so a field of only spaces cannot exceed its limit.
+
+**Malformed text.** A lone surrogate (half of a UTF-16 pair, such as the text a broken emoji leaves behind) cannot be
+stored or sent, so it is `invalid-characters` in a value, a link, an email or a username. In a field name it is
+reported as `invalid-characters` on the object that holds it, and that object's other fields are skipped.
+System.Text.Json decodes strings and field names lazily, so the .NET validator reads them without throwing and
+reports the same problem. A lone surrogate escape is not portable JSON (editors and many parsers reject it), so the
+`malformedText` fixtures list UTF-16 code units and each runtime builds the string itself.
+
+## Limits
+
+Lengths count UTF-16 code units, the unit both JavaScript and .NET use, so one emoji counts as 2.
+
+| Field | Maximum length |
+| --- | --- |
+| `name` | 80 |
+| `headline` | 120 |
+| `about` | 1000 |
+| `skills.item` | 40 |
+| `projects.title` | 80 |
+| `projects.description` | 500 |
+| `projects.technologies.item` | 40 |
+| `education.institution` | 120 |
+| `education.degree` | 120 |
+| `education.period` | 40 |
+| `education.details` | 300 |
+| `experience.organization` | 120 |
+| `experience.role` | 120 |
+| `experience.period` | 40 |
+| `experience.description` | 500 |
+| `achievements.item` | 200 |
+| `link` | 300 |
+| `email` | 254 |
+
+| List | Maximum entries |
+| --- | --- |
+| `skills` | 30 |
+| `projects` | 8 |
+| `projects.technologies` | 12 |
+| `education` | 5 |
+| `experience` | 6 |
+| `achievements` | 10 |
+
+## Reserved names
+
+`u`, `www`, `api`, `app`, `admin`, `administrator`, `root`, `mail`, `email`, `smtp`, `ftp`, `docs`, `blog`, `login`, `logout`, `signin`, `signup`, `register`, `auth`, `account`, `support`, `help`, `contact`, `about`, `status`, `static`, `assets`, `cdn`, `dev`, `test`, `staging`, `preview`, `dashboard`, `courses`, `learn`, `practice`, `games`, `guides`, `jobs`, `privacy`, `terms`, `disclaimer`, `copyright`, `portfolio`, `portfolios`, `procoderji`, `satyam`, `robots`, `sitemap`, `favicon`, `icon`, `apple-icon`, `logo`, `brand`, `og`, `new`, `edit`, `settings`, `profile`, `me`, `null`, `undefined`
+
+## Blocked words
+
+`paytm`, `phonepe`, `razorpay`, `bank`, `login`, `verify`, `password`, `otp`, `wallet`
+
+## Changing a rule
+
+1. Add or change a case in `fixtures.json` first, with the `path:code` problems you expect.
+2. Change `rules.json` if the change is a number, a name or a pattern.
+3. Change both validators if the logic changes, and keep them identical in behaviour. The validators avoid URL
+ parsers and locale-aware string functions on purpose, because JavaScript and .NET disagree about them.
+4. Run `npm test` and `dotnet test backend/ProCoderJi.slnx --configuration Release`. Both must pass.
+
+The fixture file is plain ASCII: write any other character as a Unicode escape (a backslash, `u`, and four hex
+digits) so reviewers can see exactly which character it is.
diff --git a/shared/portfolio/fixtures.json b/shared/portfolio/fixtures.json
new file mode 100644
index 0000000..cb4672a
--- /dev/null
+++ b/shared/portfolio/fixtures.json
@@ -0,0 +1,2286 @@
+{
+ "usernames": [
+ {
+ "name": "simple name",
+ "value": "riya",
+ "problems": []
+ },
+ {
+ "name": "hyphenated name",
+ "value": "riya-sharma",
+ "problems": []
+ },
+ {
+ "name": "digits",
+ "value": "a1b2c3",
+ "problems": []
+ },
+ {
+ "name": "shortest allowed",
+ "value": "abc",
+ "problems": []
+ },
+ {
+ "name": "longest allowed (30)",
+ "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "problems": []
+ },
+ {
+ "name": "digits and hyphen",
+ "value": "user-123",
+ "problems": []
+ },
+ {
+ "name": "empty",
+ "value": "",
+ "problems": [
+ "username:required"
+ ]
+ },
+ {
+ "name": "only spaces",
+ "value": " ",
+ "problems": [
+ "username:required"
+ ]
+ },
+ {
+ "name": "number",
+ "value": 123,
+ "problems": [
+ "username:invalid-type"
+ ]
+ },
+ {
+ "name": "null",
+ "value": null,
+ "problems": [
+ "username:invalid-type"
+ ]
+ },
+ {
+ "name": "array",
+ "value": [
+ "riya"
+ ],
+ "problems": [
+ "username:invalid-type"
+ ]
+ },
+ {
+ "name": "too short",
+ "value": "ab",
+ "problems": [
+ "username:too-short"
+ ]
+ },
+ {
+ "name": "one letter that is also reserved",
+ "value": "u",
+ "problems": [
+ "username:too-short"
+ ]
+ },
+ {
+ "name": "31 characters",
+ "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "problems": [
+ "username:too-long"
+ ]
+ },
+ {
+ "name": "uppercase",
+ "value": "Riya",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "underscore",
+ "value": "riya_sharma",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "leading hyphen",
+ "value": "-riya",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "trailing hyphen",
+ "value": "riya-",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "double hyphen",
+ "value": "ri--ya",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "space inside",
+ "value": "riya sharma",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "trailing newline",
+ "value": "riya\n",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "dot",
+ "value": "riya.sharma",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "devanagari",
+ "value": "\u0930\u093f\u092f\u093e",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "Kelvin sign is not folded to k",
+ "value": "\u212ariya",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "full-width letters",
+ "value": "\uff52\uff49\uff59\uff41",
+ "problems": [
+ "username:invalid-format"
+ ]
+ },
+ {
+ "name": "reserved: admin",
+ "value": "admin",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "reserved: api",
+ "value": "api",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "reserved: www",
+ "value": "www",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "reserved: brand name",
+ "value": "procoderji",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "reserved: owner site",
+ "value": "satyam",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "reserved: existing route",
+ "value": "dashboard",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "reserved: hyphenated asset name",
+ "value": "apple-icon",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "reserved and blocked",
+ "value": "login",
+ "problems": [
+ "username:reserved"
+ ]
+ },
+ {
+ "name": "blocked word with suffix",
+ "value": "paytm-help",
+ "problems": [
+ "username:blocked-word"
+ ]
+ },
+ {
+ "name": "blocked word split by hyphen",
+ "value": "pay-tm",
+ "problems": [
+ "username:blocked-word"
+ ]
+ },
+ {
+ "name": "blocked word inside",
+ "value": "my-bank-guide",
+ "problems": [
+ "username:blocked-word"
+ ]
+ },
+ {
+ "name": "blocked word with name",
+ "value": "riya-login",
+ "problems": [
+ "username:blocked-word"
+ ]
+ },
+ {
+ "name": "reserved name as part of a longer name is allowed",
+ "value": "satyam-fan",
+ "problems": []
+ }
+ ],
+ "portfolios": [
+ {
+ "name": "publish: minimal portfolio",
+ "mode": "publish",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student"
+ },
+ "problems": []
+ },
+ {
+ "name": "publish: complete portfolio",
+ "mode": "publish",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student | Web developer",
+ "about": "I build small web apps.\nI am looking for a software internship.",
+ "skills": [
+ "JavaScript",
+ "React",
+ "Node.js"
+ ],
+ "projects": [
+ {
+ "title": "Expense Tracker",
+ "link": "https://github.com/riya-example/expense-tracker",
+ "technologies": [
+ "React",
+ "Firebase"
+ ],
+ "description": "Tracks monthly spending.\nLearned state management."
+ },
+ {
+ "title": "Notes App"
+ }
+ ],
+ "education": [
+ {
+ "institution": "Example Institute of Technology",
+ "degree": "B.Tech CSE",
+ "period": "2022-2026",
+ "details": "CGPA 8.4"
+ }
+ ],
+ "experience": [
+ {
+ "organization": "Example Labs",
+ "role": "Intern",
+ "period": "Jun-Aug 2025",
+ "description": "Fixed UI bugs."
+ }
+ ],
+ "achievements": [
+ "Finalist, Example Hackathon 2025"
+ ],
+ "links": {
+ "github": "https://github.com/riya-example",
+ "linkedin": "https://www.linkedin.com/in/riya-example",
+ "website": "https://riya.example.dev",
+ "email": "riya@example.com"
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "publish: empty object",
+ "mode": "publish",
+ "value": {},
+ "problems": [
+ "name:required",
+ "headline:required"
+ ]
+ },
+ {
+ "name": "draft: empty object",
+ "mode": "draft",
+ "value": {},
+ "problems": []
+ },
+ {
+ "name": "draft: blank required fields",
+ "mode": "draft",
+ "value": {
+ "name": " ",
+ "headline": ""
+ },
+ "problems": []
+ },
+ {
+ "name": "publish: blank required fields",
+ "mode": "publish",
+ "value": {
+ "name": " ",
+ "headline": "\n"
+ },
+ "problems": [
+ "name:required",
+ "headline:required"
+ ]
+ },
+ {
+ "name": "array instead of object",
+ "mode": "publish",
+ "value": [],
+ "problems": [
+ "$:invalid-type"
+ ]
+ },
+ {
+ "name": "null instead of object",
+ "mode": "publish",
+ "value": null,
+ "problems": [
+ "$:invalid-type"
+ ]
+ },
+ {
+ "name": "string instead of object",
+ "mode": "draft",
+ "value": "riya",
+ "problems": [
+ "$:invalid-type"
+ ]
+ },
+ {
+ "name": "unknown field",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "phone": "9999999999"
+ },
+ "problems": [
+ "phone:unknown-field"
+ ]
+ },
+ {
+ "name": "unknown field named __proto__",
+ "mode": "draft",
+ "value": {
+ "name": "Riya",
+ "__proto__": {
+ "admin": true
+ }
+ },
+ "problems": [
+ "__proto__:unknown-field"
+ ]
+ },
+ {
+ "name": "name has the wrong type",
+ "mode": "draft",
+ "value": {
+ "name": 5
+ },
+ "problems": [
+ "name:invalid-type"
+ ]
+ },
+ {
+ "name": "name is null",
+ "mode": "draft",
+ "value": {
+ "name": null
+ },
+ "problems": [
+ "name:invalid-type"
+ ]
+ },
+ {
+ "name": "name at the limit (80 UTF-16 units)",
+ "mode": "publish",
+ "value": {
+ "name": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00",
+ "headline": "Final-year B.Tech CSE student"
+ },
+ "problems": []
+ },
+ {
+ "name": "name over the limit (82 UTF-16 units)",
+ "mode": "publish",
+ "value": {
+ "name": "\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00\ud83d\ude00",
+ "headline": "Final-year B.Tech CSE student"
+ },
+ "problems": [
+ "name:too-long"
+ ]
+ },
+ {
+ "name": "name with a newline",
+ "mode": "draft",
+ "value": {
+ "name": "Riya\nSharma"
+ },
+ "problems": [
+ "name:invalid-characters"
+ ]
+ },
+ {
+ "name": "headline over the limit",
+ "mode": "draft",
+ "value": {
+ "headline": "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh"
+ },
+ "problems": [
+ "headline:too-long"
+ ]
+ },
+ {
+ "name": "about at the limit",
+ "mode": "draft",
+ "value": {
+ "about": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ },
+ "problems": []
+ },
+ {
+ "name": "about over the limit",
+ "mode": "draft",
+ "value": {
+ "about": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ },
+ "problems": [
+ "about:too-long"
+ ]
+ },
+ {
+ "name": "about with a control character",
+ "mode": "draft",
+ "value": {
+ "about": "hello\u0007"
+ },
+ "problems": [
+ "about:invalid-characters"
+ ]
+ },
+ {
+ "name": "about with a right-to-left override",
+ "mode": "draft",
+ "value": {
+ "about": "hello\u202eolleh"
+ },
+ "problems": [
+ "about:invalid-characters"
+ ]
+ },
+ {
+ "name": "about with a zero-width space",
+ "mode": "draft",
+ "value": {
+ "about": "he\u200bllo"
+ },
+ "problems": [
+ "about:invalid-characters"
+ ]
+ },
+ {
+ "name": "about with an HTML tag",
+ "mode": "draft",
+ "value": {
+ "about": "Hi "
+ },
+ "problems": [
+ "about:html-like"
+ ]
+ },
+ {
+ "name": "headline with a bold tag",
+ "mode": "draft",
+ "value": {
+ "headline": "hi"
+ },
+ "problems": [
+ "headline:html-like"
+ ]
+ },
+ {
+ "name": "comparison with spaces is fine",
+ "mode": "draft",
+ "value": {
+ "about": "I like it when x < y and a <= b."
+ },
+ "problems": []
+ },
+ {
+ "name": "comparison without spaces looks like a tag",
+ "mode": "draft",
+ "value": {
+ "about": "if x"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link with credentials",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://user:pass@example.com/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link that hides the real host after @",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://github.com@evil.example/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link with a port",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://example.com:8443/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link to localhost",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://localhost/x"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link to an IPv4 address",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://192.168.0.1/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link to an IPv6 address",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://[::1]/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link with a non-ASCII host",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://m\u00fcnchen.de/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link with a punycode host",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://xn--mnchen-3ya.de/"
+ }
+ ]
+ },
+ "problems": []
+ },
+ {
+ "name": "link with an uppercase scheme and host",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "HTTPS://Example.COM/Path"
+ }
+ ]
+ },
+ "problems": []
+ },
+ {
+ "name": "link host with a Kelvin sign is not folded to k",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://\u212aanbal.com/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link with an empty label",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": "https://example..com/"
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-link"
+ ]
+ },
+ {
+ "name": "link that is not a string",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": 42
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:invalid-type"
+ ]
+ },
+ {
+ "name": "blank link is treated as empty",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": " "
+ }
+ ]
+ },
+ "problems": []
+ },
+ {
+ "name": "education without an institution",
+ "mode": "draft",
+ "value": {
+ "education": [
+ {
+ "degree": "B.Tech"
+ }
+ ]
+ },
+ "problems": [
+ "education[0].institution:required"
+ ]
+ },
+ {
+ "name": "too many education entries",
+ "mode": "draft",
+ "value": {
+ "education": [
+ {
+ "institution": "X"
+ },
+ {
+ "institution": "X"
+ },
+ {
+ "institution": "X"
+ },
+ {
+ "institution": "X"
+ },
+ {
+ "institution": "X"
+ },
+ {
+ "institution": "X"
+ }
+ ]
+ },
+ "problems": [
+ "education:too-many"
+ ]
+ },
+ {
+ "name": "education details over the limit",
+ "mode": "draft",
+ "value": {
+ "education": [
+ {
+ "institution": "X",
+ "details": "ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
+ }
+ ]
+ },
+ "problems": [
+ "education[0].details:too-long"
+ ]
+ },
+ {
+ "name": "experience without a role",
+ "mode": "draft",
+ "value": {
+ "experience": [
+ {
+ "organization": "Acme"
+ }
+ ]
+ },
+ "problems": [
+ "experience[0].role:required"
+ ]
+ },
+ {
+ "name": "experience without an organization",
+ "mode": "draft",
+ "value": {
+ "experience": [
+ {
+ "role": "Intern"
+ }
+ ]
+ },
+ "problems": [
+ "experience[0].organization:required"
+ ]
+ },
+ {
+ "name": "too many experience entries",
+ "mode": "draft",
+ "value": {
+ "experience": [
+ {
+ "organization": "A",
+ "role": "B"
+ },
+ {
+ "organization": "A",
+ "role": "B"
+ },
+ {
+ "organization": "A",
+ "role": "B"
+ },
+ {
+ "organization": "A",
+ "role": "B"
+ },
+ {
+ "organization": "A",
+ "role": "B"
+ },
+ {
+ "organization": "A",
+ "role": "B"
+ },
+ {
+ "organization": "A",
+ "role": "B"
+ }
+ ]
+ },
+ "problems": [
+ "experience:too-many"
+ ]
+ },
+ {
+ "name": "experience description over the limit",
+ "mode": "draft",
+ "value": {
+ "experience": [
+ {
+ "organization": "A",
+ "role": "B",
+ "description": "ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
+ }
+ ]
+ },
+ "problems": [
+ "experience[0].description:too-long"
+ ]
+ },
+ {
+ "name": "education entry with unknown field",
+ "mode": "draft",
+ "value": {
+ "education": [
+ {
+ "institution": "X",
+ "gpa": 9
+ }
+ ]
+ },
+ "problems": [
+ "education[0].gpa:unknown-field"
+ ]
+ },
+ {
+ "name": "achievements at the limit",
+ "mode": "draft",
+ "value": {
+ "achievements": [
+ "a0",
+ "a1",
+ "a2",
+ "a3",
+ "a4",
+ "a5",
+ "a6",
+ "a7",
+ "a8",
+ "a9"
+ ]
+ },
+ "problems": []
+ },
+ {
+ "name": "too many achievements",
+ "mode": "draft",
+ "value": {
+ "achievements": [
+ "a0",
+ "a1",
+ "a2",
+ "a3",
+ "a4",
+ "a5",
+ "a6",
+ "a7",
+ "a8",
+ "a9",
+ "a10"
+ ]
+ },
+ "problems": [
+ "achievements:too-many"
+ ]
+ },
+ {
+ "name": "achievement too long",
+ "mode": "draft",
+ "value": {
+ "achievements": [
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ ]
+ },
+ "problems": [
+ "achievements[0]:too-long"
+ ]
+ },
+ {
+ "name": "blank achievement",
+ "mode": "draft",
+ "value": {
+ "achievements": [
+ ""
+ ]
+ },
+ "problems": [
+ "achievements[0]:required"
+ ]
+ },
+ {
+ "name": "github profile",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "github": "https://github.com/riya-example"
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "github subdomain",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "github": "https://gist.github.com/riya-example"
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "github on another site",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "github": "https://example.com/riya"
+ }
+ },
+ "problems": [
+ "links.github:invalid-host"
+ ]
+ },
+ {
+ "name": "github as a prefix of another host",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "github": "https://notgithub.com/riya"
+ }
+ },
+ "problems": [
+ "links.github:invalid-host"
+ ]
+ },
+ {
+ "name": "github as a subdomain of another host",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "github": "https://github.com.evil.example/riya"
+ }
+ },
+ "problems": [
+ "links.github:invalid-host"
+ ]
+ },
+ {
+ "name": "linkedin profile",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "linkedin": "https://www.linkedin.com/in/riya-example"
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "linkedin regional subdomain",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "linkedin": "https://in.linkedin.com/in/riya-example"
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "linkedin on another site",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "linkedin": "https://example.com/in/riya"
+ }
+ },
+ "problems": [
+ "links.linkedin:invalid-host"
+ ]
+ },
+ {
+ "name": "website on any host",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "website": "https://riya.example.dev"
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "website over http",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "website": "http://riya.example.dev"
+ }
+ },
+ "problems": [
+ "links.website:invalid-link"
+ ]
+ },
+ {
+ "name": "github link over 300 characters",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "github": "https://github.com/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ }
+ },
+ "problems": [
+ "links.github:too-long"
+ ]
+ },
+ {
+ "name": "links with an unknown key",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "twitter": "https://x.com/riya"
+ }
+ },
+ "problems": [
+ "links.twitter:unknown-field"
+ ]
+ },
+ {
+ "name": "links is not an object",
+ "mode": "draft",
+ "value": {
+ "links": [
+ "https://github.com/riya"
+ ]
+ },
+ "problems": [
+ "links:invalid-type"
+ ]
+ },
+ {
+ "name": "blank link entries are treated as empty",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "github": "",
+ "linkedin": " ",
+ "website": "",
+ "email": ""
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "valid email",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "riya.sharma+jobs@example.co.in"
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "email without a domain",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "riya@"
+ }
+ },
+ "problems": [
+ "links.email:invalid-email"
+ ]
+ },
+ {
+ "name": "email without a top-level domain",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "riya@example"
+ }
+ },
+ "problems": [
+ "links.email:invalid-email"
+ ]
+ },
+ {
+ "name": "email with a mailto prefix",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "mailto:riya@example.com"
+ }
+ },
+ "problems": [
+ "links.email:invalid-email"
+ ]
+ },
+ {
+ "name": "email with a space",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "riya @example.com"
+ }
+ },
+ "problems": [
+ "links.email:invalid-email"
+ ]
+ },
+ {
+ "name": "email with a trailing newline",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "riya@example.com\n"
+ }
+ },
+ "problems": [
+ "links.email:invalid-email"
+ ]
+ },
+ {
+ "name": "email with two at signs",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "a@b@example.com"
+ }
+ },
+ "problems": [
+ "links.email:invalid-email"
+ ]
+ },
+ {
+ "name": "email over 254 characters",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "a@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.com"
+ }
+ },
+ "problems": [
+ "links.email:too-long"
+ ]
+ },
+ {
+ "name": "email local part over 64 characters",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@example.com"
+ }
+ },
+ "problems": [
+ "links.email:invalid-email"
+ ]
+ },
+ {
+ "name": "about of only spaces at the limit",
+ "mode": "draft",
+ "value": {
+ "about": " "
+ },
+ "problems": []
+ },
+ {
+ "name": "about of only spaces over the limit",
+ "mode": "draft",
+ "value": {
+ "about": " "
+ },
+ "problems": [
+ "about:too-long"
+ ]
+ },
+ {
+ "name": "name of only spaces over the limit (draft)",
+ "mode": "draft",
+ "value": {
+ "name": " "
+ },
+ "problems": [
+ "name:too-long"
+ ]
+ },
+ {
+ "name": "name of only spaces over the limit (publish)",
+ "mode": "publish",
+ "value": {
+ "name": " ",
+ "headline": "Student"
+ },
+ "problems": [
+ "name:too-long"
+ ]
+ },
+ {
+ "name": "name of only spaces at the limit is still required to publish",
+ "mode": "publish",
+ "value": {
+ "name": " ",
+ "headline": "Student"
+ },
+ "problems": [
+ "name:required"
+ ]
+ },
+ {
+ "name": "skill of only spaces over the limit",
+ "mode": "draft",
+ "value": {
+ "skills": [
+ " "
+ ]
+ },
+ "problems": [
+ "skills[0]:too-long"
+ ]
+ },
+ {
+ "name": "project link of only spaces over the limit",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "projects": [
+ {
+ "title": "App",
+ "link": " "
+ }
+ ]
+ },
+ "problems": [
+ "projects[0].link:too-long"
+ ]
+ },
+ {
+ "name": "website of only spaces over the limit",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "website": " "
+ }
+ },
+ "problems": [
+ "links.website:too-long"
+ ]
+ },
+ {
+ "name": "website of only spaces at the limit",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "website": " "
+ }
+ },
+ "problems": []
+ },
+ {
+ "name": "email of only spaces over the limit",
+ "mode": "draft",
+ "value": {
+ "name": "Riya Sharma",
+ "headline": "Final-year B.Tech CSE student",
+ "links": {
+ "email": " "
+ }
+ },
+ "problems": [
+ "links.email:too-long"
+ ]
+ },
+ {
+ "name": "several problems are all reported",
+ "mode": "publish",
+ "value": {
+ "name": "",
+ "headline": "x",
+ "skills": [
+ "",
+ "ok"
+ ],
+ "projects": [
+ {
+ "link": "http://x.example"
+ }
+ ],
+ "links": {
+ "github": "https://example.com"
+ }
+ },
+ "problems": [
+ "name:required",
+ "headline:html-like",
+ "skills[0]:required",
+ "projects[0].title:required",
+ "projects[0].link:invalid-link",
+ "links.github:invalid-host"
+ ]
+ }
+ ],
+ "malformedText": [
+ {
+ "name": "lone high surrogate in a name",
+ "field": "name",
+ "codeUnits": [
+ 97,
+ 55296,
+ 98
+ ],
+ "problems": [
+ "name:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone high surrogate at the end",
+ "field": "name",
+ "codeUnits": [
+ 97,
+ 55296
+ ],
+ "problems": [
+ "name:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone low surrogate",
+ "field": "name",
+ "codeUnits": [
+ 56320
+ ],
+ "problems": [
+ "name:invalid-characters"
+ ]
+ },
+ {
+ "name": "low surrogate before a high one",
+ "field": "name",
+ "codeUnits": [
+ 56320,
+ 55296
+ ],
+ "problems": [
+ "name:invalid-characters"
+ ]
+ },
+ {
+ "name": "valid surrogate pair is fine",
+ "field": "name",
+ "codeUnits": [
+ 97,
+ 55357,
+ 56832,
+ 98
+ ],
+ "problems": []
+ },
+ {
+ "name": "lone surrogate in a multi-line field",
+ "field": "about",
+ "codeUnits": [
+ 108,
+ 105,
+ 110,
+ 101,
+ 32,
+ 111,
+ 110,
+ 101,
+ 10,
+ 55296
+ ],
+ "problems": [
+ "about:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in a skill",
+ "field": "skill",
+ "codeUnits": [
+ 56320
+ ],
+ "problems": [
+ "skills[0]:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in a project link",
+ "field": "projectLink",
+ "codeUnits": [
+ 104,
+ 116,
+ 116,
+ 112,
+ 115,
+ 58,
+ 47,
+ 47,
+ 101,
+ 120,
+ 97,
+ 109,
+ 112,
+ 108,
+ 101,
+ 46,
+ 99,
+ 111,
+ 109,
+ 47,
+ 55296
+ ],
+ "problems": [
+ "projects[0].link:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in a website",
+ "field": "website",
+ "codeUnits": [
+ 104,
+ 116,
+ 116,
+ 112,
+ 115,
+ 58,
+ 47,
+ 47,
+ 101,
+ 120,
+ 97,
+ 109,
+ 112,
+ 108,
+ 101,
+ 46,
+ 99,
+ 111,
+ 109,
+ 47,
+ 56320
+ ],
+ "problems": [
+ "links.website:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in an email",
+ "field": "email",
+ "codeUnits": [
+ 114,
+ 105,
+ 121,
+ 97,
+ 64,
+ 101,
+ 120,
+ 97,
+ 109,
+ 112,
+ 108,
+ 101,
+ 46,
+ 99,
+ 111,
+ 109,
+ 55296
+ ],
+ "problems": [
+ "links.email:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in a username",
+ "field": "username",
+ "codeUnits": [
+ 97,
+ 98,
+ 99,
+ 55296
+ ],
+ "problems": [
+ "username:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in a field name",
+ "field": "key",
+ "codeUnits": [
+ 97,
+ 55296
+ ],
+ "problems": [
+ "$:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone low surrogate as a whole field name",
+ "field": "key",
+ "codeUnits": [
+ 56320
+ ],
+ "problems": [
+ "$:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in a field name inside links",
+ "field": "linksKey",
+ "codeUnits": [
+ 55296
+ ],
+ "problems": [
+ "links:invalid-characters"
+ ]
+ },
+ {
+ "name": "lone surrogate in a project field name",
+ "field": "projectKey",
+ "codeUnits": [
+ 56320
+ ],
+ "problems": [
+ "projects[0]:invalid-characters"
+ ]
+ },
+ {
+ "name": "valid pair in a field name is only an unknown field",
+ "field": "key",
+ "codeUnits": [
+ 97,
+ 55357,
+ 56832
+ ],
+ "problems": [
+ "a\ud83d\ude00:unknown-field"
+ ]
+ },
+ {
+ "name": "valid pair in a username is well-formed but not allowed",
+ "field": "username",
+ "codeUnits": [
+ 97,
+ 98,
+ 55357,
+ 56832
+ ],
+ "problems": [
+ "username:invalid-format"
+ ]
+ }
+ ]
+}
diff --git a/shared/portfolio/rules.json b/shared/portfolio/rules.json
new file mode 100644
index 0000000..2b79153
--- /dev/null
+++ b/shared/portfolio/rules.json
@@ -0,0 +1,67 @@
+{
+ "version": 1,
+ "requiredForPublish": ["name", "headline"],
+ "maxItems": {
+ "skills": 30,
+ "projects": 8,
+ "projects.technologies": 12,
+ "education": 5,
+ "experience": 6,
+ "achievements": 10
+ },
+ "maxLength": {
+ "name": 80,
+ "headline": 120,
+ "about": 1000,
+ "skills.item": 40,
+ "projects.title": 80,
+ "projects.description": 500,
+ "projects.technologies.item": 40,
+ "education.institution": 120,
+ "education.degree": 120,
+ "education.period": 40,
+ "education.details": 300,
+ "experience.organization": 120,
+ "experience.role": 120,
+ "experience.period": 40,
+ "experience.description": 500,
+ "achievements.item": 200,
+ "link": 300,
+ "email": 254
+ },
+ "multilineFields": [
+ "about",
+ "projects.description",
+ "education.details",
+ "experience.description"
+ ],
+ "text": {
+ "controlCharacters": "[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069\\ufeff]",
+ "htmlLike": "<[A-Za-z/!?]"
+ },
+ "link": {
+ "scheme": "https://",
+ "forbiddenCharacters": "[\\u0000-\\u0020\\u0022\\u003c\\u003e\\u005c\\u0060\\u007f-\\u009f\\u200b-\\u200f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069\\ufeff]",
+ "hostPattern": "^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+(?:[a-z]{2,}|xn--[a-z0-9]+(?:-[a-z0-9]+)*)$",
+ "hostRestrictions": {
+ "links.github": ["github.com"],
+ "links.linkedin": ["linkedin.com"]
+ }
+ },
+ "emailPattern": "^[A-Za-z0-9._%+-]{1,64}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\\.)+[A-Za-z]{2,}$",
+ "username": {
+ "minLength": 3,
+ "maxLength": 30,
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
+ "reserved": [
+ "u", "www", "api", "app", "admin", "administrator", "root", "mail", "email", "smtp", "ftp",
+ "docs", "blog", "login", "logout", "signin", "signup", "register", "auth", "account",
+ "support", "help", "contact", "about", "status", "static", "assets", "cdn", "dev", "test",
+ "staging", "preview", "dashboard", "courses", "learn", "practice", "games", "guides", "jobs",
+ "privacy", "terms", "disclaimer", "copyright", "portfolio", "portfolios", "procoderji",
+ "satyam", "robots", "sitemap", "favicon", "icon", "apple-icon", "logo", "brand", "og",
+ "new", "edit", "settings", "profile", "me", "null", "undefined"
+ ],
+ "blockedWords": ["paytm", "phonepe", "razorpay", "bank", "login", "verify", "password", "otp", "wallet"]
+ }
+}
diff --git a/src/features/portfolio/portfolio.test.ts b/src/features/portfolio/portfolio.test.ts
new file mode 100644
index 0000000..1d632ce
--- /dev/null
+++ b/src/features/portfolio/portfolio.test.ts
@@ -0,0 +1,148 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import fixtures from "../../../shared/portfolio/fixtures.json" with { type: "json" };
+import { PORTFOLIO_RULES } from "./rules.ts";
+import {
+ problemKey,
+ validatePortfolio,
+ validateUsername,
+} from "./validation.ts";
+import type { PortfolioMode } from "./types.ts";
+
+// The backend runs the same fixtures (PortfolioValidatorTests). Add a case there first when
+// a rule changes, so both runtimes keep agreeing.
+
+const keys = (problems: Parameters[0][]) =>
+ problems.map(problemKey).sort();
+
+test("every shared username fixture gives the expected problems", () => {
+ for (const fixture of fixtures.usernames)
+ assert.deepEqual(
+ keys(validateUsername(fixture.value)),
+ [...fixture.problems].sort(),
+ fixture.name,
+ );
+});
+
+test("every shared portfolio fixture gives the expected problems", () => {
+ for (const fixture of fixtures.portfolios)
+ assert.deepEqual(
+ keys(validatePortfolio(fixture.value, fixture.mode as PortfolioMode)),
+ [...fixture.problems].sort(),
+ fixture.name,
+ );
+});
+
+// Text with a lone surrogate cannot be stored in JSON that .NET parses, so these fixtures list
+// the UTF-16 code units and each runtime builds the string itself.
+test("every malformed-text fixture gives the expected problems", () => {
+ assert.ok(fixtures.malformedText.length > 0);
+ for (const fixture of fixtures.malformedText) {
+ const text = String.fromCharCode(...fixture.codeUnits);
+ const problems =
+ fixture.field === "username"
+ ? validateUsername(text)
+ : validatePortfolio(portfolioWith(fixture.field, text), "draft");
+ assert.deepEqual(keys(problems), [...fixture.problems].sort(), fixture.name);
+ }
+});
+
+function portfolioWith(field: string, text: string): unknown {
+ switch (field) {
+ case "name": return { name: text };
+ case "about": return { about: text };
+ case "skill": return { skills: [text] };
+ case "projectLink": return { projects: [{ title: "App", link: text }] };
+ case "website": return { links: { website: text } };
+ case "email": return { links: { email: text } };
+ case "key": return { [text]: 1 };
+ case "linksKey": return { links: { [text]: 1 } };
+ case "projectKey": return { projects: [{ title: "App", [text]: 1 }] };
+ default: throw new Error(`Unknown fixture field: ${field}`);
+ }
+}
+
+test("lone surrogate escapes in parsed JSON text are problems", () => {
+ // The backslash comes from a char code so this source never holds a surrogate escape.
+ const escape = (hex: string) => String.fromCharCode(92) + "u" + hex;
+ const values = '{"name":"' + escape("d800") + '","skills":["' + escape("dc00") + '"]}';
+ assert.deepEqual(keys(validatePortfolio(JSON.parse(values), "draft")), [
+ "name:invalid-characters",
+ "skills[0]:invalid-characters",
+ ]);
+ const name = '{"links":{"' + escape("d800") + '":1}}';
+ assert.deepEqual(keys(validatePortfolio(JSON.parse(name), "draft")), ["links:invalid-characters"]);
+});
+
+test("the fixtures cover valid and invalid input and both modes", () => {
+ assert.ok(fixtures.usernames.some((f) => f.problems.length === 0));
+ assert.ok(fixtures.usernames.some((f) => f.problems.length > 0));
+ assert.ok(fixtures.portfolios.some((f) => f.mode === "draft"));
+ assert.ok(fixtures.portfolios.some((f) => f.mode === "publish"));
+ assert.ok(fixtures.portfolios.some((f) => f.problems.length === 0));
+});
+
+test("every problem code the validator can report appears in a fixture", () => {
+ const seen = new Set(
+ [...fixtures.usernames, ...fixtures.portfolios].flatMap((f) =>
+ f.problems.map((p) => p.split(":")[1]),
+ ),
+ );
+ for (const code of [
+ "required",
+ "invalid-type",
+ "unknown-field",
+ "too-short",
+ "too-long",
+ "too-many",
+ "invalid-characters",
+ "html-like",
+ "invalid-format",
+ "invalid-link",
+ "invalid-host",
+ "invalid-email",
+ "reserved",
+ "blocked-word",
+ ])
+ assert.ok(seen.has(code), `no fixture produces ${code}`);
+});
+
+test("reserved names cover every public route and the names listed in ADR 0002", () => {
+ const reserved = new Set(PORTFOLIO_RULES.username.reserved);
+ for (const name of [
+ "www", "api", "app", "admin", "mail", "docs", "blog", "login", "support",
+ "help", "procoderji", "satyam", "u", "learn", "practice", "account",
+ "dashboard", "games", "guides", "jobs", "about", "privacy", "terms",
+ "disclaimer", "copyright", "courses",
+ ])
+ assert.ok(reserved.has(name), name);
+});
+
+test("reserved names and blocked words are well-formed usernames in lowercase", () => {
+ const pattern = new RegExp(PORTFOLIO_RULES.username.pattern);
+ for (const name of PORTFOLIO_RULES.username.reserved)
+ assert.ok(pattern.test(name), `reserved name ${name} can never be claimed anyway`);
+ for (const word of PORTFOLIO_RULES.username.blockedWords)
+ assert.ok(/^[a-z0-9]+$/.test(word), `blocked word ${word} must be lowercase letters and digits`);
+});
+
+test("every limit in the rules is a positive whole number", () => {
+ for (const [key, max] of Object.entries(PORTFOLIO_RULES.maxLength))
+ assert.ok(Number.isInteger(max) && max > 0, key);
+ for (const [key, max] of Object.entries(PORTFOLIO_RULES.maxItems))
+ assert.ok(Number.isInteger(max) && max > 0, key);
+});
+
+test("validating does not modify the input", () => {
+ const input = { name: "Riya", skills: ["a"], links: { github: "https://github.com/riya" } };
+ const copy = structuredClone(input);
+ validatePortfolio(input, "publish");
+ assert.deepEqual(input, copy);
+});
+
+test("a huge value of the wrong type is rejected quickly", () => {
+ const started = Date.now();
+ assert.deepEqual(keys(validatePortfolio({ name: "x".repeat(2_000_000) }, "draft")), ["name:too-long"]);
+ assert.deepEqual(keys(validatePortfolio({ links: { github: "https://" + "a".repeat(2_000_000) } }, "draft")), ["links.github:too-long"]);
+ assert.ok(Date.now() - started < 1_000);
+});
diff --git a/src/features/portfolio/rules.ts b/src/features/portfolio/rules.ts
new file mode 100644
index 0000000..e6e5db4
--- /dev/null
+++ b/src/features/portfolio/rules.ts
@@ -0,0 +1,30 @@
+import rulesData from "../../../shared/portfolio/rules.json" with { type: "json" };
+
+/**
+ * The portfolio rules shared with the backend. Numbers, reserved names and patterns live in
+ * `shared/portfolio/rules.json`; the backend reads the same file.
+ */
+export interface PortfolioRules {
+ version: number;
+ requiredForPublish: readonly string[];
+ maxItems: Readonly>;
+ maxLength: Readonly>;
+ multilineFields: readonly string[];
+ text: { controlCharacters: string; htmlLike: string };
+ link: {
+ scheme: string;
+ forbiddenCharacters: string;
+ hostPattern: string;
+ hostRestrictions: Readonly>;
+ };
+ emailPattern: string;
+ username: {
+ minLength: number;
+ maxLength: number;
+ pattern: string;
+ reserved: readonly string[];
+ blockedWords: readonly string[];
+ };
+}
+
+export const PORTFOLIO_RULES: PortfolioRules = rulesData;
diff --git a/src/features/portfolio/types.ts b/src/features/portfolio/types.ts
new file mode 100644
index 0000000..8b80a3d
--- /dev/null
+++ b/src/features/portfolio/types.ts
@@ -0,0 +1,65 @@
+export type PortfolioMode = "draft" | "publish";
+
+export type PortfolioProblemCode =
+ | "required"
+ | "invalid-type"
+ | "unknown-field"
+ | "too-short"
+ | "too-long"
+ | "too-many"
+ | "invalid-characters"
+ | "html-like"
+ | "invalid-format"
+ | "invalid-link"
+ | "invalid-host"
+ | "invalid-email"
+ | "reserved"
+ | "blocked-word";
+
+/** A validation problem at a field path such as `projects[0].link`. */
+export interface PortfolioProblem {
+ path: string;
+ code: PortfolioProblemCode;
+}
+
+export interface PortfolioProject {
+ title: string;
+ link?: string;
+ technologies?: string[];
+ description?: string;
+}
+
+export interface PortfolioEducation {
+ institution: string;
+ degree?: string;
+ period?: string;
+ details?: string;
+}
+
+export interface PortfolioExperience {
+ organization: string;
+ role: string;
+ period?: string;
+ description?: string;
+}
+
+export interface PortfolioLinks {
+ github?: string;
+ linkedin?: string;
+ website?: string;
+ /** An email address. The page builds the `mailto:` link; students never store one. */
+ email?: string;
+}
+
+/** A valid portfolio. A draft may omit `name` and `headline`; a published one may not. */
+export interface PortfolioData {
+ name?: string;
+ headline?: string;
+ about?: string;
+ skills?: string[];
+ projects?: PortfolioProject[];
+ education?: PortfolioEducation[];
+ experience?: PortfolioExperience[];
+ achievements?: string[];
+ links?: PortfolioLinks;
+}
diff --git a/src/features/portfolio/validation.ts b/src/features/portfolio/validation.ts
new file mode 100644
index 0000000..7c26a62
--- /dev/null
+++ b/src/features/portfolio/validation.ts
@@ -0,0 +1,276 @@
+import { PORTFOLIO_RULES as RULES } from "./rules.ts";
+import type {
+ PortfolioMode,
+ PortfolioProblem,
+ PortfolioProblemCode,
+} from "./types.ts";
+
+/*
+ * Portfolio validation. The .NET validator in
+ * backend/src/ProCoderJi.Api/Features/Portfolio/ implements the same rules, and both run
+ * against shared/portfolio/fixtures.json. Keep the two in step: when a check changes, add a
+ * fixture first. The rules avoid URL parsers and locale-aware string functions on purpose,
+ * because those behave differently in JavaScript and .NET.
+ */
+
+const CONTROL = new RegExp(RULES.text.controlCharacters);
+const HTML_LIKE = new RegExp(RULES.text.htmlLike);
+const LINK_FORBIDDEN = new RegExp(RULES.link.forbiddenCharacters);
+const HOST = new RegExp(RULES.link.hostPattern);
+const EMAIL = new RegExp(RULES.emailPattern);
+const USERNAME = new RegExp(RULES.username.pattern);
+const RESERVED = new Set(RULES.username.reserved);
+const MULTILINE = new Set(RULES.multilineFields);
+const BLANK = /^[ \t\r\n]*$/;
+const LINE_BREAK = /[\r\n]/;
+const AUTHORITY_END = /[/?#]/;
+
+type Problems = PortfolioProblem[];
+type Value = unknown;
+type ItemCheck = (path: string, item: Value) => void;
+
+function add(problems: Problems, path: string, code: PortfolioProblemCode) {
+ problems.push({ path, code });
+}
+
+function limit(table: Readonly>, key: string): number {
+ const value = table[key];
+ if (value === undefined) throw new Error(`Missing portfolio rule: ${key}`);
+ return value;
+}
+
+/** Lower-cases A-Z only, so other characters such as the Kelvin sign are never folded. */
+function asciiLower(text: string): string {
+ return text.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
+}
+
+/** False when the text has a lone surrogate half, which no encoding can represent. */
+function isWellFormed(text: string): boolean {
+ for (let i = 0; i < text.length; i++) {
+ const unit = text.charCodeAt(i);
+ if (unit >= 0xd800 && unit <= 0xdbff) {
+ const next = text.charCodeAt(i + 1);
+ if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
+ i++;
+ } else if (unit >= 0xdc00 && unit <= 0xdfff) return false;
+ }
+ return true;
+}
+
+function isObject(value: Value): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function get(object: Record, key: string): Value {
+ return Object.hasOwn(object, key) ? object[key] : undefined;
+}
+
+/**
+ * Reports unknown field names. Returns false, after reporting `invalid-characters` for the
+ * object itself, when a field name is not well-formed text; the caller then skips that object.
+ */
+function checkUnknownFields(
+ problems: Problems,
+ path: string,
+ object: Record,
+ allowed: readonly string[],
+): boolean {
+ const keys = Object.keys(object);
+ if (keys.some((key) => !isWellFormed(key))) {
+ add(problems, path || "$", "invalid-characters");
+ return false;
+ }
+ for (const key of keys)
+ if (!allowed.includes(key))
+ add(problems, path ? `${path}.${key}` : key, "unknown-field");
+ return true;
+}
+
+function checkText(
+ problems: Problems,
+ path: string,
+ value: Value,
+ key: string,
+ required: boolean,
+) {
+ if (value === undefined) {
+ if (required) add(problems, path, "required");
+ return;
+ }
+ if (typeof value !== "string") return add(problems, path, "invalid-type");
+ if (!isWellFormed(value)) return add(problems, path, "invalid-characters");
+ // Length comes before the blank check, so a field of only spaces cannot exceed its limit.
+ if (value.length > limit(RULES.maxLength, key)) return add(problems, path, "too-long");
+ if (BLANK.test(value)) {
+ if (required) add(problems, path, "required");
+ return;
+ }
+ if (CONTROL.test(value) || (!MULTILINE.has(key) && LINE_BREAK.test(value)))
+ return add(problems, path, "invalid-characters");
+ if (HTML_LIKE.test(value)) add(problems, path, "html-like");
+}
+
+/** An optional https link. `hostKey` selects the host restriction, such as `links.github`. */
+function checkLink(
+ problems: Problems,
+ path: string,
+ value: Value,
+ hostKey?: string,
+) {
+ if (value === undefined) return;
+ if (typeof value !== "string") return add(problems, path, "invalid-type");
+ if (!isWellFormed(value)) return add(problems, path, "invalid-characters");
+ if (value.length > limit(RULES.maxLength, "link")) return add(problems, path, "too-long");
+ if (BLANK.test(value)) return;
+ const scheme = RULES.link.scheme;
+ if (
+ LINK_FORBIDDEN.test(value) ||
+ asciiLower(value.slice(0, scheme.length)) !== scheme
+ )
+ return add(problems, path, "invalid-link");
+ const rest = value.slice(scheme.length);
+ const end = rest.search(AUTHORITY_END);
+ const authority = end === -1 ? rest : rest.slice(0, end);
+ // The host pattern allows only letters, digits, hyphens and dots, so credentials
+ // (`user@host`), ports (`host:8443`), IP literals and empty hosts all fail it.
+ const host = asciiLower(authority);
+ if (!HOST.test(host)) return add(problems, path, "invalid-link");
+ const allowed = hostKey ? RULES.link.hostRestrictions[hostKey] : undefined;
+ if (allowed && !allowed.some((domain) => host === domain || host.endsWith(`.${domain}`)))
+ add(problems, path, "invalid-host");
+}
+
+function checkEmail(problems: Problems, path: string, value: Value) {
+ if (value === undefined) return;
+ if (typeof value !== "string") return add(problems, path, "invalid-type");
+ if (!isWellFormed(value)) return add(problems, path, "invalid-characters");
+ if (value.length > limit(RULES.maxLength, "email")) return add(problems, path, "too-long");
+ if (BLANK.test(value)) return;
+ if (!EMAIL.test(value)) add(problems, path, "invalid-email");
+}
+
+function checkList(
+ problems: Problems,
+ path: string,
+ value: Value,
+ key: string,
+ checkItem: ItemCheck,
+) {
+ if (value === undefined) return;
+ if (!Array.isArray(value)) return add(problems, path, "invalid-type");
+ if (value.length > limit(RULES.maxItems, key)) return add(problems, path, "too-many");
+ value.forEach((item, index) => checkItem(`${path}[${index}]`, item));
+}
+
+function checkObjectItem(
+ problems: Problems,
+ path: string,
+ item: Value,
+ allowed: readonly string[],
+ checkFields: (object: Record) => void,
+) {
+ if (!isObject(item)) return add(problems, path, "invalid-type");
+ if (checkUnknownFields(problems, path, item, allowed)) checkFields(item);
+}
+
+/** Checks a username for the public address `/u/`. */
+export function validateUsername(value: unknown): PortfolioProblem[] {
+ const problems: Problems = [];
+ const path = "username";
+ const rules = RULES.username;
+ if (typeof value !== "string") return [{ path, code: "invalid-type" }];
+ if (!isWellFormed(value)) return [{ path, code: "invalid-characters" }];
+ if (BLANK.test(value)) return [{ path, code: "required" }];
+ if (value.length < rules.minLength) return [{ path, code: "too-short" }];
+ if (value.length > rules.maxLength) return [{ path, code: "too-long" }];
+ if (!USERNAME.test(value)) return [{ path, code: "invalid-format" }];
+ if (RESERVED.has(value)) return [{ path, code: "reserved" }];
+ const squashed = value.replaceAll("-", "");
+ if (rules.blockedWords.some((word) => squashed.includes(word)))
+ add(problems, path, "blocked-word");
+ return problems;
+}
+
+/**
+ * Checks untrusted portfolio data. A draft only has to be well-formed; publishing also needs
+ * the fields listed in `requiredForPublish`. An empty result means the data is valid.
+ */
+export function validatePortfolio(
+ value: unknown,
+ mode: PortfolioMode,
+): PortfolioProblem[] {
+ const problems: Problems = [];
+ if (!isObject(value)) return [{ path: "$", code: "invalid-type" }];
+ const rootOk = checkUnknownFields(problems, "", value, [
+ "name",
+ "headline",
+ "about",
+ "skills",
+ "projects",
+ "education",
+ "experience",
+ "achievements",
+ "links",
+ ]);
+ if (!rootOk) return problems;
+
+ const requiredOnPublish = (key: string) =>
+ mode === "publish" && RULES.requiredForPublish.includes(key);
+ checkText(problems, "name", get(value, "name"), "name", requiredOnPublish("name"));
+ checkText(problems, "headline", get(value, "headline"), "headline", requiredOnPublish("headline"));
+ checkText(problems, "about", get(value, "about"), "about", false);
+
+ checkList(problems, "skills", get(value, "skills"), "skills", (path, item) =>
+ checkText(problems, path, item, "skills.item", true),
+ );
+
+ checkList(problems, "projects", get(value, "projects"), "projects", (path, item) =>
+ checkObjectItem(problems, path, item, ["title", "link", "technologies", "description"], (project) => {
+ checkText(problems, `${path}.title`, get(project, "title"), "projects.title", true);
+ checkLink(problems, `${path}.link`, get(project, "link"));
+ checkList(problems, `${path}.technologies`, get(project, "technologies"), "projects.technologies", (techPath, tech) =>
+ checkText(problems, techPath, tech, "projects.technologies.item", true),
+ );
+ checkText(problems, `${path}.description`, get(project, "description"), "projects.description", false);
+ }),
+ );
+
+ checkList(problems, "education", get(value, "education"), "education", (path, item) =>
+ checkObjectItem(problems, path, item, ["institution", "degree", "period", "details"], (entry) => {
+ checkText(problems, `${path}.institution`, get(entry, "institution"), "education.institution", true);
+ checkText(problems, `${path}.degree`, get(entry, "degree"), "education.degree", false);
+ checkText(problems, `${path}.period`, get(entry, "period"), "education.period", false);
+ checkText(problems, `${path}.details`, get(entry, "details"), "education.details", false);
+ }),
+ );
+
+ checkList(problems, "experience", get(value, "experience"), "experience", (path, item) =>
+ checkObjectItem(problems, path, item, ["organization", "role", "period", "description"], (entry) => {
+ checkText(problems, `${path}.organization`, get(entry, "organization"), "experience.organization", true);
+ checkText(problems, `${path}.role`, get(entry, "role"), "experience.role", true);
+ checkText(problems, `${path}.period`, get(entry, "period"), "experience.period", false);
+ checkText(problems, `${path}.description`, get(entry, "description"), "experience.description", false);
+ }),
+ );
+
+ checkList(problems, "achievements", get(value, "achievements"), "achievements", (path, item) =>
+ checkText(problems, path, item, "achievements.item", true),
+ );
+
+ const links = get(value, "links");
+ if (links !== undefined) {
+ if (!isObject(links)) add(problems, "links", "invalid-type");
+ else if (checkUnknownFields(problems, "links", links, ["github", "linkedin", "website", "email"])) {
+ checkLink(problems, "links.github", get(links, "github"), "links.github");
+ checkLink(problems, "links.linkedin", get(links, "linkedin"), "links.linkedin");
+ checkLink(problems, "links.website", get(links, "website"));
+ checkEmail(problems, "links.email", get(links, "email"));
+ }
+ }
+ return problems;
+}
+
+/** The stable `path:code` form used by the shared fixtures and for messages. */
+export function problemKey(problem: PortfolioProblem): string {
+ return `${problem.path}:${problem.code}`;
+}