From cce58615764bc9a6a8de6bf0b9c70d0bee462c13 Mon Sep 17 00:00:00 2001 From: Mistium <92952823+Mistium@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:18:01 +0000 Subject: [PATCH 1/8] Add a script that dumps every registered route scripts/route_dump.py follows main.osl's router setup through the route functions and prints each route with its middleware chain, sorted, plus the method order per path, so a reorganisation can be diffed. --- scripts/route_dump.py | 127 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 scripts/route_dump.py diff --git a/scripts/route_dump.py b/scripts/route_dump.py new file mode 100644 index 0000000..f515c29 --- /dev/null +++ b/scripts/route_dump.py @@ -0,0 +1,127 @@ +import glob +import os +import re +import sys + +METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "ANY"} +DEF = re.compile(r"^def (\w+)\((.*?)\)\s*\w*\s*\($") +CALL = re.compile(r"^\s*(?:void\s+)?(\w+)\((.*)\)\s*$") +GROUP = re.compile(r"^\s*\*serve\.Router (\w+) = (\w+)\.group\(\"([^\"]*)\"\)\s*$") +ROUTER = re.compile(r"^\s*\*serve\.Router (\w+) = serve\.new\(\)\s*$") +METHOD = re.compile(r"^\s*(\w+)\.(\w+)\((.*)\)\s*$") + + +def split_args(text): + args, depth, current, quote = [], 0, "", False + for i, ch in enumerate(text): + if ch == "\"" and (i == 0 or text[i - 1] != "\\"): + quote = not quote + if not quote: + if ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + elif ch == "," and depth == 0: + args.append(current.strip()) + current = "" + continue + current += ch + if current.strip(): + args.append(current.strip()) + return args + + +def load_functions(root): + functions = {} + for path in glob.glob(os.path.join(root, "**", "*.osl"), recursive=True): + if path.endswith(".test.osl") or "/.claude/" in path or "/.opal/" in path: + continue + lines = open(path).read().split("\n") + i = 0 + while i < len(lines): + match = DEF.match(lines[i]) + if match: + params = [p.strip().split()[-1] for p in split_args(match.group(2)) if p.strip()] + body = [] + i += 1 + while i < len(lines) and lines[i] != ")": + body.append(lines[i]) + i += 1 + functions[match.group(1)] = (params, body) + i += 1 + return functions + + +class Dump: + def __init__(self, functions): + self.functions = functions + self.routes = [] + self.order = {} + + def run(self, lines, routers, depth=0): + if depth > 20: + return + for line in lines: + if line.strip().startswith("//"): + continue + match = ROUTER.match(line) + if match: + routers[match.group(1)] = {"prefix": "", "mw": []} + continue + match = GROUP.match(line) + if match and match.group(2) in routers: + parent = routers[match.group(2)] + routers[match.group(1)] = {"prefix": parent["prefix"] + match.group(3), "mw": list(parent["mw"])} + continue + match = METHOD.match(line) + if match and match.group(1) in routers: + router = routers[match.group(1)] + name = match.group(2) + args = split_args(match.group(3)) + if name == "use": + router["mw"].extend(args) + elif name.upper() in METHODS or name in ("static", "ws", "staticFile"): + path = router["prefix"] + args[0].strip("\"") + kind = name.upper() if name.upper() in METHODS else name + self.routes.append("%s %s | %s" % (kind, path, " , ".join(router["mw"] + args[1:]))) + self.order.setdefault(path, []).append(kind) + else: + self.routes.append("%s.%s(%s) on %s" % ("router", name, ", ".join(args), router["prefix"] or "/")) + continue + match = CALL.match(line) + if match and match.group(1) in self.functions: + params, body = self.functions[match.group(1)] + args = split_args(match.group(2)) + scope = dict(routers) + for param, arg in zip(params, args): + if arg in routers: + scope[param] = routers[arg] + if any(re.search(r"\b(%s)\.\w+\(" % "|".join(map(re.escape, scope)), l) for l in body): + self.run(body, scope, depth + 1) + + +def main(): + root = sys.argv[1] if len(sys.argv) > 1 else "." + functions = load_functions(root) + lines = [l for l in open(os.path.join(root, "main.osl")).read().split("\n")] + top = [] + skip = False + for line in lines: + if DEF.match(line): + skip = True + continue + if skip: + if line == ")": + skip = False + continue + top.append(line) + dump = Dump(functions) + dump.run(top, {}) + for route in sorted(dump.routes): + print(route) + for path in sorted(dump.order): + print("ORDER %s %s" % (path, ",".join(dump.order[path]))) + + +if __name__ == "__main__": + main() From 3c711ff5aa4a3e55f393ac44617459175351e648 Mon Sep 17 00:00:00 2001 From: Mistium <92952823+Mistium@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:18:01 +0000 Subject: [PATCH 2/8] Move the API's sources into src/ directories A pure git mv so history follows each file. Imports are fixed in the next commit. --- admin-user-policy.osl => src/admin/admin-user-policy.osl | 0 .../admin/admin-user-policy.test.osl | 0 admin-users.osl => src/admin/admin-users.osl | 0 admin.osl => src/admin/admin.osl | 0 banned-owners.osl => src/admin/banned-owners.osl | 0 banned-owners.test.osl => src/admin/banned-owners.test.osl | 0 errors.osl => src/admin/errors.osl | 0 errors.test.osl => src/admin/errors.test.osl | 0 funnel-events-policy.osl => src/admin/funnel-events-policy.osl | 0 .../admin/funnel-events-policy.test.osl | 0 funnel-events.osl => src/admin/funnel-events.osl | 0 project-moderation.osl => src/admin/project-moderation.osl | 0 account-controls.osl => src/auth/account-controls.osl | 0 agreement.osl => src/auth/agreement.osl | 0 auth.osl => src/auth/auth.osl | 0 editor-session-policy.osl => src/auth/editor-session-policy.osl | 0 .../auth/editor-session-policy.test.osl | 0 privacy-policy.osl => src/auth/privacy-policy.osl | 0 privacy-policy.test.osl => src/auth/privacy-policy.test.osl | 0 settings.osl => src/auth/settings.osl | 0 user-identity.osl => src/auth/user-identity.osl | 0 .../classroom/classroom-billing-policy.osl | 0 .../classroom/classroom-billing-policy.test.osl | 0 classroom-billing.osl => src/classroom/classroom-billing.osl | 0 classroom-groups.osl => src/classroom/classroom-groups.osl | 0 .../classroom/classroom-legal-policy.osl | 0 .../classroom/classroom-legal-policy.test.osl | 0 classroom-legal.osl => src/classroom/classroom-legal.osl | 0 classroom-policy.osl => src/classroom/classroom-policy.osl | 0 .../classroom/classroom-policy.test.osl | 0 classroom-present.osl => src/classroom/classroom-present.osl | 0 classroom-release.osl => src/classroom/classroom-release.osl | 0 classroom-schools.osl => src/classroom/classroom-schools.osl | 0 .../classroom/classroom-switch-policy.osl | 0 .../classroom/classroom-switch-policy.test.osl | 0 classroom-switch.osl => src/classroom/classroom-switch.osl | 0 .../classroom/classroom-switch.test.osl | 0 classrooms.osl => src/classroom/classrooms.osl | 0 bounties.osl => src/community/bounties.osl | 0 challenge-ranking.osl => src/community/challenge-ranking.osl | 0 .../community/challenge-ranking.test.osl | 0 comments.osl => src/community/comments.osl | 0 community-tools.osl => src/community/community-tools.osl | 0 development-links.osl => src/community/development-links.osl | 0 .../community/development-links.test.osl | 0 development.osl => src/community/development.osl | 0 groups.osl => src/community/groups.osl | 0 .../community/like-milestone-policy.osl | 0 like-milestones.osl => src/community/like-milestones.osl | 0 .../community/like-milestones.test.osl | 0 news.osl => src/community/news.osl | 0 notifications.osl => src/community/notifications.osl | 0 remote-milestones.osl => src/community/remote-milestones.osl | 0 report-evidence.osl => src/community/report-evidence.osl | 0 reports.osl => src/community/reports.osl | 0 social.osl => src/community/social.osl | 0 standing.osl => src/community/standing.osl | 0 database.osl => src/core/database.osl | 0 utils.osl => src/core/utils.osl | 0 utils.test.osl => src/core/utils.test.osl | 0 game-data.osl => src/games/game-data.osl | 0 game-inventory.osl => src/games/game-inventory.osl | 0 game-inventory.test.osl => src/games/game-inventory.test.osl | 0 game-marketplace.osl => src/games/game-marketplace.osl | 0 git-inspection.osl => src/git/git-inspection.osl | 0 gitea.osl => src/git/gitea.osl | 0 pull-metadata.osl => src/git/pull-metadata.osl | 0 pull-metadata.test.osl => src/git/pull-metadata.test.osl | 0 pulls.osl => src/git/pulls.osl | 0 commit-management.osl => src/history/commit-management.osl | 0 history-delta.osl => src/history/history-delta.osl | 0 history-delta.test.osl => src/history/history-delta.test.osl | 0 history-migration.osl => src/history/history-migration.osl | 0 .../history/history-migration.test.osl | 0 cache-policy.osl => src/http/cache-policy.osl | 0 cache-policy.test.osl => src/http/cache-policy.test.osl | 0 middleware.osl => src/http/middleware.osl | 0 request-body.osl => src/http/request-body.osl | 0 request-limits.osl => src/http/request-limits.osl | 0 request-limits.test.osl => src/http/request-limits.test.osl | 0 extensions.osl => src/projects/extensions.osl | 0 fork-visibility.test.osl => src/projects/fork-visibility.test.osl | 0 .../projects/live-collaboration-policy.osl | 0 .../projects/live-collaboration-policy.test.osl | 0 live-collaboration.osl => src/projects/live-collaboration.osl | 0 monetize.osl => src/projects/monetize.osl | 0 .../projects/project-sharing-policy.osl | 0 .../projects/project-sharing-policy.test.osl | 0 projects.osl => src/projects/projects.osl | 0 random-projects.osl => src/projects/random-projects.osl | 0 random-projects.test.osl => src/projects/random-projects.test.osl | 0 related-projects.osl => src/projects/related-projects.osl | 0 .../projects/related-projects.test.osl | 0 .../projects/release-access-policy.osl | 0 .../projects/release-access-policy.test.osl | 0 search-ranking.osl => src/projects/search-ranking.osl | 0 search-ranking.test.osl => src/projects/search-ranking.test.osl | 0 game-realtime.osl => src/realtime/game-realtime.osl | 0 site-realtime.osl => src/realtime/site-realtime.osl | 0 perks.osl => src/rotur/perks.osl | 0 rotur-app.osl => src/rotur/rotur-app.osl | 0 rotur-badges.osl => src/rotur/rotur-badges.osl | 0 rotur-badges.test.osl => src/rotur/rotur-badges.test.osl | 0 rotur-payments.osl => src/rotur/rotur-payments.osl | 0 rotur-payments.test.osl => src/rotur/rotur-payments.test.osl | 0 rotur-plan.osl => src/rotur/rotur-plan.osl | 0 rotur-plan.test.osl => src/rotur/rotur-plan.test.osl | 0 rotur-signals.osl => src/rotur/rotur-signals.osl | 0 rotur-signals.test.osl => src/rotur/rotur-signals.test.osl | 0 rotur-webhooks.osl => src/rotur/rotur-webhooks.osl | 0 rotur-webhooks.test.osl => src/rotur/rotur-webhooks.test.osl | 0 upgrade-policy.osl => src/rotur/upgrade-policy.osl | 0 upgrade-policy.test.osl => src/rotur/upgrade-policy.test.osl | 0 vanity-policy.osl => src/rotur/vanity-policy.osl | 0 vanity-policy.test.osl => src/rotur/vanity-policy.test.osl | 0 archive-security.osl => src/storage/archive-security.osl | 0 .../storage/archive-security.test.osl | 0 asset-routing.osl => src/storage/asset-routing.osl | 0 asset-routing.test.osl => src/storage/asset-routing.test.osl | 0 backpack-policy.osl => src/storage/backpack-policy.osl | 0 backpack-policy.test.osl => src/storage/backpack-policy.test.osl | 0 backpack.osl => src/storage/backpack.osl | 0 quota-policy.osl => src/storage/quota-policy.osl | 0 quota-policy.test.osl => src/storage/quota-policy.test.osl | 0 r2-snapshot-serving.osl => src/storage/r2-snapshot-serving.osl | 0 .../storage/r2-snapshot-serving.test.osl | 0 r2-upload-queue.osl => src/storage/r2-upload-queue.osl | 0 r2-upload-queue.test.osl => src/storage/r2-upload-queue.test.osl | 0 r2.osl => src/storage/r2.osl | 0 storage-addressing.osl => src/storage/storage-addressing.osl | 0 .../storage/storage-addressing.test.osl | 0 .../storage/storage-ledger-policy.osl | 0 .../storage/storage-ledger-policy.test.osl | 0 storage-ledger.osl => src/storage/storage-ledger.osl | 0 storage-ledger.test.osl => src/storage/storage-ledger.test.osl | 0 storage-usage.osl => src/storage/storage-usage.osl | 0 storage.osl => src/storage/storage.osl | 0 thumbnail-access.osl => src/storage/thumbnail-access.osl | 0 .../storage/thumbnail-access.test.osl | 0 139 files changed, 0 insertions(+), 0 deletions(-) rename admin-user-policy.osl => src/admin/admin-user-policy.osl (100%) rename admin-user-policy.test.osl => src/admin/admin-user-policy.test.osl (100%) rename admin-users.osl => src/admin/admin-users.osl (100%) rename admin.osl => src/admin/admin.osl (100%) rename banned-owners.osl => src/admin/banned-owners.osl (100%) rename banned-owners.test.osl => src/admin/banned-owners.test.osl (100%) rename errors.osl => src/admin/errors.osl (100%) rename errors.test.osl => src/admin/errors.test.osl (100%) rename funnel-events-policy.osl => src/admin/funnel-events-policy.osl (100%) rename funnel-events-policy.test.osl => src/admin/funnel-events-policy.test.osl (100%) rename funnel-events.osl => src/admin/funnel-events.osl (100%) rename project-moderation.osl => src/admin/project-moderation.osl (100%) rename account-controls.osl => src/auth/account-controls.osl (100%) rename agreement.osl => src/auth/agreement.osl (100%) rename auth.osl => src/auth/auth.osl (100%) rename editor-session-policy.osl => src/auth/editor-session-policy.osl (100%) rename editor-session-policy.test.osl => src/auth/editor-session-policy.test.osl (100%) rename privacy-policy.osl => src/auth/privacy-policy.osl (100%) rename privacy-policy.test.osl => src/auth/privacy-policy.test.osl (100%) rename settings.osl => src/auth/settings.osl (100%) rename user-identity.osl => src/auth/user-identity.osl (100%) rename classroom-billing-policy.osl => src/classroom/classroom-billing-policy.osl (100%) rename classroom-billing-policy.test.osl => src/classroom/classroom-billing-policy.test.osl (100%) rename classroom-billing.osl => src/classroom/classroom-billing.osl (100%) rename classroom-groups.osl => src/classroom/classroom-groups.osl (100%) rename classroom-legal-policy.osl => src/classroom/classroom-legal-policy.osl (100%) rename classroom-legal-policy.test.osl => src/classroom/classroom-legal-policy.test.osl (100%) rename classroom-legal.osl => src/classroom/classroom-legal.osl (100%) rename classroom-policy.osl => src/classroom/classroom-policy.osl (100%) rename classroom-policy.test.osl => src/classroom/classroom-policy.test.osl (100%) rename classroom-present.osl => src/classroom/classroom-present.osl (100%) rename classroom-release.osl => src/classroom/classroom-release.osl (100%) rename classroom-schools.osl => src/classroom/classroom-schools.osl (100%) rename classroom-switch-policy.osl => src/classroom/classroom-switch-policy.osl (100%) rename classroom-switch-policy.test.osl => src/classroom/classroom-switch-policy.test.osl (100%) rename classroom-switch.osl => src/classroom/classroom-switch.osl (100%) rename classroom-switch.test.osl => src/classroom/classroom-switch.test.osl (100%) rename classrooms.osl => src/classroom/classrooms.osl (100%) rename bounties.osl => src/community/bounties.osl (100%) rename challenge-ranking.osl => src/community/challenge-ranking.osl (100%) rename challenge-ranking.test.osl => src/community/challenge-ranking.test.osl (100%) rename comments.osl => src/community/comments.osl (100%) rename community-tools.osl => src/community/community-tools.osl (100%) rename development-links.osl => src/community/development-links.osl (100%) rename development-links.test.osl => src/community/development-links.test.osl (100%) rename development.osl => src/community/development.osl (100%) rename groups.osl => src/community/groups.osl (100%) rename like-milestone-policy.osl => src/community/like-milestone-policy.osl (100%) rename like-milestones.osl => src/community/like-milestones.osl (100%) rename like-milestones.test.osl => src/community/like-milestones.test.osl (100%) rename news.osl => src/community/news.osl (100%) rename notifications.osl => src/community/notifications.osl (100%) rename remote-milestones.osl => src/community/remote-milestones.osl (100%) rename report-evidence.osl => src/community/report-evidence.osl (100%) rename reports.osl => src/community/reports.osl (100%) rename social.osl => src/community/social.osl (100%) rename standing.osl => src/community/standing.osl (100%) rename database.osl => src/core/database.osl (100%) rename utils.osl => src/core/utils.osl (100%) rename utils.test.osl => src/core/utils.test.osl (100%) rename game-data.osl => src/games/game-data.osl (100%) rename game-inventory.osl => src/games/game-inventory.osl (100%) rename game-inventory.test.osl => src/games/game-inventory.test.osl (100%) rename game-marketplace.osl => src/games/game-marketplace.osl (100%) rename git-inspection.osl => src/git/git-inspection.osl (100%) rename gitea.osl => src/git/gitea.osl (100%) rename pull-metadata.osl => src/git/pull-metadata.osl (100%) rename pull-metadata.test.osl => src/git/pull-metadata.test.osl (100%) rename pulls.osl => src/git/pulls.osl (100%) rename commit-management.osl => src/history/commit-management.osl (100%) rename history-delta.osl => src/history/history-delta.osl (100%) rename history-delta.test.osl => src/history/history-delta.test.osl (100%) rename history-migration.osl => src/history/history-migration.osl (100%) rename history-migration.test.osl => src/history/history-migration.test.osl (100%) rename cache-policy.osl => src/http/cache-policy.osl (100%) rename cache-policy.test.osl => src/http/cache-policy.test.osl (100%) rename middleware.osl => src/http/middleware.osl (100%) rename request-body.osl => src/http/request-body.osl (100%) rename request-limits.osl => src/http/request-limits.osl (100%) rename request-limits.test.osl => src/http/request-limits.test.osl (100%) rename extensions.osl => src/projects/extensions.osl (100%) rename fork-visibility.test.osl => src/projects/fork-visibility.test.osl (100%) rename live-collaboration-policy.osl => src/projects/live-collaboration-policy.osl (100%) rename live-collaboration-policy.test.osl => src/projects/live-collaboration-policy.test.osl (100%) rename live-collaboration.osl => src/projects/live-collaboration.osl (100%) rename monetize.osl => src/projects/monetize.osl (100%) rename project-sharing-policy.osl => src/projects/project-sharing-policy.osl (100%) rename project-sharing-policy.test.osl => src/projects/project-sharing-policy.test.osl (100%) rename projects.osl => src/projects/projects.osl (100%) rename random-projects.osl => src/projects/random-projects.osl (100%) rename random-projects.test.osl => src/projects/random-projects.test.osl (100%) rename related-projects.osl => src/projects/related-projects.osl (100%) rename related-projects.test.osl => src/projects/related-projects.test.osl (100%) rename release-access-policy.osl => src/projects/release-access-policy.osl (100%) rename release-access-policy.test.osl => src/projects/release-access-policy.test.osl (100%) rename search-ranking.osl => src/projects/search-ranking.osl (100%) rename search-ranking.test.osl => src/projects/search-ranking.test.osl (100%) rename game-realtime.osl => src/realtime/game-realtime.osl (100%) rename site-realtime.osl => src/realtime/site-realtime.osl (100%) rename perks.osl => src/rotur/perks.osl (100%) rename rotur-app.osl => src/rotur/rotur-app.osl (100%) rename rotur-badges.osl => src/rotur/rotur-badges.osl (100%) rename rotur-badges.test.osl => src/rotur/rotur-badges.test.osl (100%) rename rotur-payments.osl => src/rotur/rotur-payments.osl (100%) rename rotur-payments.test.osl => src/rotur/rotur-payments.test.osl (100%) rename rotur-plan.osl => src/rotur/rotur-plan.osl (100%) rename rotur-plan.test.osl => src/rotur/rotur-plan.test.osl (100%) rename rotur-signals.osl => src/rotur/rotur-signals.osl (100%) rename rotur-signals.test.osl => src/rotur/rotur-signals.test.osl (100%) rename rotur-webhooks.osl => src/rotur/rotur-webhooks.osl (100%) rename rotur-webhooks.test.osl => src/rotur/rotur-webhooks.test.osl (100%) rename upgrade-policy.osl => src/rotur/upgrade-policy.osl (100%) rename upgrade-policy.test.osl => src/rotur/upgrade-policy.test.osl (100%) rename vanity-policy.osl => src/rotur/vanity-policy.osl (100%) rename vanity-policy.test.osl => src/rotur/vanity-policy.test.osl (100%) rename archive-security.osl => src/storage/archive-security.osl (100%) rename archive-security.test.osl => src/storage/archive-security.test.osl (100%) rename asset-routing.osl => src/storage/asset-routing.osl (100%) rename asset-routing.test.osl => src/storage/asset-routing.test.osl (100%) rename backpack-policy.osl => src/storage/backpack-policy.osl (100%) rename backpack-policy.test.osl => src/storage/backpack-policy.test.osl (100%) rename backpack.osl => src/storage/backpack.osl (100%) rename quota-policy.osl => src/storage/quota-policy.osl (100%) rename quota-policy.test.osl => src/storage/quota-policy.test.osl (100%) rename r2-snapshot-serving.osl => src/storage/r2-snapshot-serving.osl (100%) rename r2-snapshot-serving.test.osl => src/storage/r2-snapshot-serving.test.osl (100%) rename r2-upload-queue.osl => src/storage/r2-upload-queue.osl (100%) rename r2-upload-queue.test.osl => src/storage/r2-upload-queue.test.osl (100%) rename r2.osl => src/storage/r2.osl (100%) rename storage-addressing.osl => src/storage/storage-addressing.osl (100%) rename storage-addressing.test.osl => src/storage/storage-addressing.test.osl (100%) rename storage-ledger-policy.osl => src/storage/storage-ledger-policy.osl (100%) rename storage-ledger-policy.test.osl => src/storage/storage-ledger-policy.test.osl (100%) rename storage-ledger.osl => src/storage/storage-ledger.osl (100%) rename storage-ledger.test.osl => src/storage/storage-ledger.test.osl (100%) rename storage-usage.osl => src/storage/storage-usage.osl (100%) rename storage.osl => src/storage/storage.osl (100%) rename thumbnail-access.osl => src/storage/thumbnail-access.osl (100%) rename thumbnail-access.test.osl => src/storage/thumbnail-access.test.osl (100%) diff --git a/admin-user-policy.osl b/src/admin/admin-user-policy.osl similarity index 100% rename from admin-user-policy.osl rename to src/admin/admin-user-policy.osl diff --git a/admin-user-policy.test.osl b/src/admin/admin-user-policy.test.osl similarity index 100% rename from admin-user-policy.test.osl rename to src/admin/admin-user-policy.test.osl diff --git a/admin-users.osl b/src/admin/admin-users.osl similarity index 100% rename from admin-users.osl rename to src/admin/admin-users.osl diff --git a/admin.osl b/src/admin/admin.osl similarity index 100% rename from admin.osl rename to src/admin/admin.osl diff --git a/banned-owners.osl b/src/admin/banned-owners.osl similarity index 100% rename from banned-owners.osl rename to src/admin/banned-owners.osl diff --git a/banned-owners.test.osl b/src/admin/banned-owners.test.osl similarity index 100% rename from banned-owners.test.osl rename to src/admin/banned-owners.test.osl diff --git a/errors.osl b/src/admin/errors.osl similarity index 100% rename from errors.osl rename to src/admin/errors.osl diff --git a/errors.test.osl b/src/admin/errors.test.osl similarity index 100% rename from errors.test.osl rename to src/admin/errors.test.osl diff --git a/funnel-events-policy.osl b/src/admin/funnel-events-policy.osl similarity index 100% rename from funnel-events-policy.osl rename to src/admin/funnel-events-policy.osl diff --git a/funnel-events-policy.test.osl b/src/admin/funnel-events-policy.test.osl similarity index 100% rename from funnel-events-policy.test.osl rename to src/admin/funnel-events-policy.test.osl diff --git a/funnel-events.osl b/src/admin/funnel-events.osl similarity index 100% rename from funnel-events.osl rename to src/admin/funnel-events.osl diff --git a/project-moderation.osl b/src/admin/project-moderation.osl similarity index 100% rename from project-moderation.osl rename to src/admin/project-moderation.osl diff --git a/account-controls.osl b/src/auth/account-controls.osl similarity index 100% rename from account-controls.osl rename to src/auth/account-controls.osl diff --git a/agreement.osl b/src/auth/agreement.osl similarity index 100% rename from agreement.osl rename to src/auth/agreement.osl diff --git a/auth.osl b/src/auth/auth.osl similarity index 100% rename from auth.osl rename to src/auth/auth.osl diff --git a/editor-session-policy.osl b/src/auth/editor-session-policy.osl similarity index 100% rename from editor-session-policy.osl rename to src/auth/editor-session-policy.osl diff --git a/editor-session-policy.test.osl b/src/auth/editor-session-policy.test.osl similarity index 100% rename from editor-session-policy.test.osl rename to src/auth/editor-session-policy.test.osl diff --git a/privacy-policy.osl b/src/auth/privacy-policy.osl similarity index 100% rename from privacy-policy.osl rename to src/auth/privacy-policy.osl diff --git a/privacy-policy.test.osl b/src/auth/privacy-policy.test.osl similarity index 100% rename from privacy-policy.test.osl rename to src/auth/privacy-policy.test.osl diff --git a/settings.osl b/src/auth/settings.osl similarity index 100% rename from settings.osl rename to src/auth/settings.osl diff --git a/user-identity.osl b/src/auth/user-identity.osl similarity index 100% rename from user-identity.osl rename to src/auth/user-identity.osl diff --git a/classroom-billing-policy.osl b/src/classroom/classroom-billing-policy.osl similarity index 100% rename from classroom-billing-policy.osl rename to src/classroom/classroom-billing-policy.osl diff --git a/classroom-billing-policy.test.osl b/src/classroom/classroom-billing-policy.test.osl similarity index 100% rename from classroom-billing-policy.test.osl rename to src/classroom/classroom-billing-policy.test.osl diff --git a/classroom-billing.osl b/src/classroom/classroom-billing.osl similarity index 100% rename from classroom-billing.osl rename to src/classroom/classroom-billing.osl diff --git a/classroom-groups.osl b/src/classroom/classroom-groups.osl similarity index 100% rename from classroom-groups.osl rename to src/classroom/classroom-groups.osl diff --git a/classroom-legal-policy.osl b/src/classroom/classroom-legal-policy.osl similarity index 100% rename from classroom-legal-policy.osl rename to src/classroom/classroom-legal-policy.osl diff --git a/classroom-legal-policy.test.osl b/src/classroom/classroom-legal-policy.test.osl similarity index 100% rename from classroom-legal-policy.test.osl rename to src/classroom/classroom-legal-policy.test.osl diff --git a/classroom-legal.osl b/src/classroom/classroom-legal.osl similarity index 100% rename from classroom-legal.osl rename to src/classroom/classroom-legal.osl diff --git a/classroom-policy.osl b/src/classroom/classroom-policy.osl similarity index 100% rename from classroom-policy.osl rename to src/classroom/classroom-policy.osl diff --git a/classroom-policy.test.osl b/src/classroom/classroom-policy.test.osl similarity index 100% rename from classroom-policy.test.osl rename to src/classroom/classroom-policy.test.osl diff --git a/classroom-present.osl b/src/classroom/classroom-present.osl similarity index 100% rename from classroom-present.osl rename to src/classroom/classroom-present.osl diff --git a/classroom-release.osl b/src/classroom/classroom-release.osl similarity index 100% rename from classroom-release.osl rename to src/classroom/classroom-release.osl diff --git a/classroom-schools.osl b/src/classroom/classroom-schools.osl similarity index 100% rename from classroom-schools.osl rename to src/classroom/classroom-schools.osl diff --git a/classroom-switch-policy.osl b/src/classroom/classroom-switch-policy.osl similarity index 100% rename from classroom-switch-policy.osl rename to src/classroom/classroom-switch-policy.osl diff --git a/classroom-switch-policy.test.osl b/src/classroom/classroom-switch-policy.test.osl similarity index 100% rename from classroom-switch-policy.test.osl rename to src/classroom/classroom-switch-policy.test.osl diff --git a/classroom-switch.osl b/src/classroom/classroom-switch.osl similarity index 100% rename from classroom-switch.osl rename to src/classroom/classroom-switch.osl diff --git a/classroom-switch.test.osl b/src/classroom/classroom-switch.test.osl similarity index 100% rename from classroom-switch.test.osl rename to src/classroom/classroom-switch.test.osl diff --git a/classrooms.osl b/src/classroom/classrooms.osl similarity index 100% rename from classrooms.osl rename to src/classroom/classrooms.osl diff --git a/bounties.osl b/src/community/bounties.osl similarity index 100% rename from bounties.osl rename to src/community/bounties.osl diff --git a/challenge-ranking.osl b/src/community/challenge-ranking.osl similarity index 100% rename from challenge-ranking.osl rename to src/community/challenge-ranking.osl diff --git a/challenge-ranking.test.osl b/src/community/challenge-ranking.test.osl similarity index 100% rename from challenge-ranking.test.osl rename to src/community/challenge-ranking.test.osl diff --git a/comments.osl b/src/community/comments.osl similarity index 100% rename from comments.osl rename to src/community/comments.osl diff --git a/community-tools.osl b/src/community/community-tools.osl similarity index 100% rename from community-tools.osl rename to src/community/community-tools.osl diff --git a/development-links.osl b/src/community/development-links.osl similarity index 100% rename from development-links.osl rename to src/community/development-links.osl diff --git a/development-links.test.osl b/src/community/development-links.test.osl similarity index 100% rename from development-links.test.osl rename to src/community/development-links.test.osl diff --git a/development.osl b/src/community/development.osl similarity index 100% rename from development.osl rename to src/community/development.osl diff --git a/groups.osl b/src/community/groups.osl similarity index 100% rename from groups.osl rename to src/community/groups.osl diff --git a/like-milestone-policy.osl b/src/community/like-milestone-policy.osl similarity index 100% rename from like-milestone-policy.osl rename to src/community/like-milestone-policy.osl diff --git a/like-milestones.osl b/src/community/like-milestones.osl similarity index 100% rename from like-milestones.osl rename to src/community/like-milestones.osl diff --git a/like-milestones.test.osl b/src/community/like-milestones.test.osl similarity index 100% rename from like-milestones.test.osl rename to src/community/like-milestones.test.osl diff --git a/news.osl b/src/community/news.osl similarity index 100% rename from news.osl rename to src/community/news.osl diff --git a/notifications.osl b/src/community/notifications.osl similarity index 100% rename from notifications.osl rename to src/community/notifications.osl diff --git a/remote-milestones.osl b/src/community/remote-milestones.osl similarity index 100% rename from remote-milestones.osl rename to src/community/remote-milestones.osl diff --git a/report-evidence.osl b/src/community/report-evidence.osl similarity index 100% rename from report-evidence.osl rename to src/community/report-evidence.osl diff --git a/reports.osl b/src/community/reports.osl similarity index 100% rename from reports.osl rename to src/community/reports.osl diff --git a/social.osl b/src/community/social.osl similarity index 100% rename from social.osl rename to src/community/social.osl diff --git a/standing.osl b/src/community/standing.osl similarity index 100% rename from standing.osl rename to src/community/standing.osl diff --git a/database.osl b/src/core/database.osl similarity index 100% rename from database.osl rename to src/core/database.osl diff --git a/utils.osl b/src/core/utils.osl similarity index 100% rename from utils.osl rename to src/core/utils.osl diff --git a/utils.test.osl b/src/core/utils.test.osl similarity index 100% rename from utils.test.osl rename to src/core/utils.test.osl diff --git a/game-data.osl b/src/games/game-data.osl similarity index 100% rename from game-data.osl rename to src/games/game-data.osl diff --git a/game-inventory.osl b/src/games/game-inventory.osl similarity index 100% rename from game-inventory.osl rename to src/games/game-inventory.osl diff --git a/game-inventory.test.osl b/src/games/game-inventory.test.osl similarity index 100% rename from game-inventory.test.osl rename to src/games/game-inventory.test.osl diff --git a/game-marketplace.osl b/src/games/game-marketplace.osl similarity index 100% rename from game-marketplace.osl rename to src/games/game-marketplace.osl diff --git a/git-inspection.osl b/src/git/git-inspection.osl similarity index 100% rename from git-inspection.osl rename to src/git/git-inspection.osl diff --git a/gitea.osl b/src/git/gitea.osl similarity index 100% rename from gitea.osl rename to src/git/gitea.osl diff --git a/pull-metadata.osl b/src/git/pull-metadata.osl similarity index 100% rename from pull-metadata.osl rename to src/git/pull-metadata.osl diff --git a/pull-metadata.test.osl b/src/git/pull-metadata.test.osl similarity index 100% rename from pull-metadata.test.osl rename to src/git/pull-metadata.test.osl diff --git a/pulls.osl b/src/git/pulls.osl similarity index 100% rename from pulls.osl rename to src/git/pulls.osl diff --git a/commit-management.osl b/src/history/commit-management.osl similarity index 100% rename from commit-management.osl rename to src/history/commit-management.osl diff --git a/history-delta.osl b/src/history/history-delta.osl similarity index 100% rename from history-delta.osl rename to src/history/history-delta.osl diff --git a/history-delta.test.osl b/src/history/history-delta.test.osl similarity index 100% rename from history-delta.test.osl rename to src/history/history-delta.test.osl diff --git a/history-migration.osl b/src/history/history-migration.osl similarity index 100% rename from history-migration.osl rename to src/history/history-migration.osl diff --git a/history-migration.test.osl b/src/history/history-migration.test.osl similarity index 100% rename from history-migration.test.osl rename to src/history/history-migration.test.osl diff --git a/cache-policy.osl b/src/http/cache-policy.osl similarity index 100% rename from cache-policy.osl rename to src/http/cache-policy.osl diff --git a/cache-policy.test.osl b/src/http/cache-policy.test.osl similarity index 100% rename from cache-policy.test.osl rename to src/http/cache-policy.test.osl diff --git a/middleware.osl b/src/http/middleware.osl similarity index 100% rename from middleware.osl rename to src/http/middleware.osl diff --git a/request-body.osl b/src/http/request-body.osl similarity index 100% rename from request-body.osl rename to src/http/request-body.osl diff --git a/request-limits.osl b/src/http/request-limits.osl similarity index 100% rename from request-limits.osl rename to src/http/request-limits.osl diff --git a/request-limits.test.osl b/src/http/request-limits.test.osl similarity index 100% rename from request-limits.test.osl rename to src/http/request-limits.test.osl diff --git a/extensions.osl b/src/projects/extensions.osl similarity index 100% rename from extensions.osl rename to src/projects/extensions.osl diff --git a/fork-visibility.test.osl b/src/projects/fork-visibility.test.osl similarity index 100% rename from fork-visibility.test.osl rename to src/projects/fork-visibility.test.osl diff --git a/live-collaboration-policy.osl b/src/projects/live-collaboration-policy.osl similarity index 100% rename from live-collaboration-policy.osl rename to src/projects/live-collaboration-policy.osl diff --git a/live-collaboration-policy.test.osl b/src/projects/live-collaboration-policy.test.osl similarity index 100% rename from live-collaboration-policy.test.osl rename to src/projects/live-collaboration-policy.test.osl diff --git a/live-collaboration.osl b/src/projects/live-collaboration.osl similarity index 100% rename from live-collaboration.osl rename to src/projects/live-collaboration.osl diff --git a/monetize.osl b/src/projects/monetize.osl similarity index 100% rename from monetize.osl rename to src/projects/monetize.osl diff --git a/project-sharing-policy.osl b/src/projects/project-sharing-policy.osl similarity index 100% rename from project-sharing-policy.osl rename to src/projects/project-sharing-policy.osl diff --git a/project-sharing-policy.test.osl b/src/projects/project-sharing-policy.test.osl similarity index 100% rename from project-sharing-policy.test.osl rename to src/projects/project-sharing-policy.test.osl diff --git a/projects.osl b/src/projects/projects.osl similarity index 100% rename from projects.osl rename to src/projects/projects.osl diff --git a/random-projects.osl b/src/projects/random-projects.osl similarity index 100% rename from random-projects.osl rename to src/projects/random-projects.osl diff --git a/random-projects.test.osl b/src/projects/random-projects.test.osl similarity index 100% rename from random-projects.test.osl rename to src/projects/random-projects.test.osl diff --git a/related-projects.osl b/src/projects/related-projects.osl similarity index 100% rename from related-projects.osl rename to src/projects/related-projects.osl diff --git a/related-projects.test.osl b/src/projects/related-projects.test.osl similarity index 100% rename from related-projects.test.osl rename to src/projects/related-projects.test.osl diff --git a/release-access-policy.osl b/src/projects/release-access-policy.osl similarity index 100% rename from release-access-policy.osl rename to src/projects/release-access-policy.osl diff --git a/release-access-policy.test.osl b/src/projects/release-access-policy.test.osl similarity index 100% rename from release-access-policy.test.osl rename to src/projects/release-access-policy.test.osl diff --git a/search-ranking.osl b/src/projects/search-ranking.osl similarity index 100% rename from search-ranking.osl rename to src/projects/search-ranking.osl diff --git a/search-ranking.test.osl b/src/projects/search-ranking.test.osl similarity index 100% rename from search-ranking.test.osl rename to src/projects/search-ranking.test.osl diff --git a/game-realtime.osl b/src/realtime/game-realtime.osl similarity index 100% rename from game-realtime.osl rename to src/realtime/game-realtime.osl diff --git a/site-realtime.osl b/src/realtime/site-realtime.osl similarity index 100% rename from site-realtime.osl rename to src/realtime/site-realtime.osl diff --git a/perks.osl b/src/rotur/perks.osl similarity index 100% rename from perks.osl rename to src/rotur/perks.osl diff --git a/rotur-app.osl b/src/rotur/rotur-app.osl similarity index 100% rename from rotur-app.osl rename to src/rotur/rotur-app.osl diff --git a/rotur-badges.osl b/src/rotur/rotur-badges.osl similarity index 100% rename from rotur-badges.osl rename to src/rotur/rotur-badges.osl diff --git a/rotur-badges.test.osl b/src/rotur/rotur-badges.test.osl similarity index 100% rename from rotur-badges.test.osl rename to src/rotur/rotur-badges.test.osl diff --git a/rotur-payments.osl b/src/rotur/rotur-payments.osl similarity index 100% rename from rotur-payments.osl rename to src/rotur/rotur-payments.osl diff --git a/rotur-payments.test.osl b/src/rotur/rotur-payments.test.osl similarity index 100% rename from rotur-payments.test.osl rename to src/rotur/rotur-payments.test.osl diff --git a/rotur-plan.osl b/src/rotur/rotur-plan.osl similarity index 100% rename from rotur-plan.osl rename to src/rotur/rotur-plan.osl diff --git a/rotur-plan.test.osl b/src/rotur/rotur-plan.test.osl similarity index 100% rename from rotur-plan.test.osl rename to src/rotur/rotur-plan.test.osl diff --git a/rotur-signals.osl b/src/rotur/rotur-signals.osl similarity index 100% rename from rotur-signals.osl rename to src/rotur/rotur-signals.osl diff --git a/rotur-signals.test.osl b/src/rotur/rotur-signals.test.osl similarity index 100% rename from rotur-signals.test.osl rename to src/rotur/rotur-signals.test.osl diff --git a/rotur-webhooks.osl b/src/rotur/rotur-webhooks.osl similarity index 100% rename from rotur-webhooks.osl rename to src/rotur/rotur-webhooks.osl diff --git a/rotur-webhooks.test.osl b/src/rotur/rotur-webhooks.test.osl similarity index 100% rename from rotur-webhooks.test.osl rename to src/rotur/rotur-webhooks.test.osl diff --git a/upgrade-policy.osl b/src/rotur/upgrade-policy.osl similarity index 100% rename from upgrade-policy.osl rename to src/rotur/upgrade-policy.osl diff --git a/upgrade-policy.test.osl b/src/rotur/upgrade-policy.test.osl similarity index 100% rename from upgrade-policy.test.osl rename to src/rotur/upgrade-policy.test.osl diff --git a/vanity-policy.osl b/src/rotur/vanity-policy.osl similarity index 100% rename from vanity-policy.osl rename to src/rotur/vanity-policy.osl diff --git a/vanity-policy.test.osl b/src/rotur/vanity-policy.test.osl similarity index 100% rename from vanity-policy.test.osl rename to src/rotur/vanity-policy.test.osl diff --git a/archive-security.osl b/src/storage/archive-security.osl similarity index 100% rename from archive-security.osl rename to src/storage/archive-security.osl diff --git a/archive-security.test.osl b/src/storage/archive-security.test.osl similarity index 100% rename from archive-security.test.osl rename to src/storage/archive-security.test.osl diff --git a/asset-routing.osl b/src/storage/asset-routing.osl similarity index 100% rename from asset-routing.osl rename to src/storage/asset-routing.osl diff --git a/asset-routing.test.osl b/src/storage/asset-routing.test.osl similarity index 100% rename from asset-routing.test.osl rename to src/storage/asset-routing.test.osl diff --git a/backpack-policy.osl b/src/storage/backpack-policy.osl similarity index 100% rename from backpack-policy.osl rename to src/storage/backpack-policy.osl diff --git a/backpack-policy.test.osl b/src/storage/backpack-policy.test.osl similarity index 100% rename from backpack-policy.test.osl rename to src/storage/backpack-policy.test.osl diff --git a/backpack.osl b/src/storage/backpack.osl similarity index 100% rename from backpack.osl rename to src/storage/backpack.osl diff --git a/quota-policy.osl b/src/storage/quota-policy.osl similarity index 100% rename from quota-policy.osl rename to src/storage/quota-policy.osl diff --git a/quota-policy.test.osl b/src/storage/quota-policy.test.osl similarity index 100% rename from quota-policy.test.osl rename to src/storage/quota-policy.test.osl diff --git a/r2-snapshot-serving.osl b/src/storage/r2-snapshot-serving.osl similarity index 100% rename from r2-snapshot-serving.osl rename to src/storage/r2-snapshot-serving.osl diff --git a/r2-snapshot-serving.test.osl b/src/storage/r2-snapshot-serving.test.osl similarity index 100% rename from r2-snapshot-serving.test.osl rename to src/storage/r2-snapshot-serving.test.osl diff --git a/r2-upload-queue.osl b/src/storage/r2-upload-queue.osl similarity index 100% rename from r2-upload-queue.osl rename to src/storage/r2-upload-queue.osl diff --git a/r2-upload-queue.test.osl b/src/storage/r2-upload-queue.test.osl similarity index 100% rename from r2-upload-queue.test.osl rename to src/storage/r2-upload-queue.test.osl diff --git a/r2.osl b/src/storage/r2.osl similarity index 100% rename from r2.osl rename to src/storage/r2.osl diff --git a/storage-addressing.osl b/src/storage/storage-addressing.osl similarity index 100% rename from storage-addressing.osl rename to src/storage/storage-addressing.osl diff --git a/storage-addressing.test.osl b/src/storage/storage-addressing.test.osl similarity index 100% rename from storage-addressing.test.osl rename to src/storage/storage-addressing.test.osl diff --git a/storage-ledger-policy.osl b/src/storage/storage-ledger-policy.osl similarity index 100% rename from storage-ledger-policy.osl rename to src/storage/storage-ledger-policy.osl diff --git a/storage-ledger-policy.test.osl b/src/storage/storage-ledger-policy.test.osl similarity index 100% rename from storage-ledger-policy.test.osl rename to src/storage/storage-ledger-policy.test.osl diff --git a/storage-ledger.osl b/src/storage/storage-ledger.osl similarity index 100% rename from storage-ledger.osl rename to src/storage/storage-ledger.osl diff --git a/storage-ledger.test.osl b/src/storage/storage-ledger.test.osl similarity index 100% rename from storage-ledger.test.osl rename to src/storage/storage-ledger.test.osl diff --git a/storage-usage.osl b/src/storage/storage-usage.osl similarity index 100% rename from storage-usage.osl rename to src/storage/storage-usage.osl diff --git a/storage.osl b/src/storage/storage.osl similarity index 100% rename from storage.osl rename to src/storage/storage.osl diff --git a/thumbnail-access.osl b/src/storage/thumbnail-access.osl similarity index 100% rename from thumbnail-access.osl rename to src/storage/thumbnail-access.osl diff --git a/thumbnail-access.test.osl b/src/storage/thumbnail-access.test.osl similarity index 100% rename from thumbnail-access.test.osl rename to src/storage/thumbnail-access.test.osl From 42ad2962e6bdc759766c0f7a0470b6057c45a005 Mon Sep 17 00:00:00 2001 From: Mistium <92952823+Mistium@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:20:03 +0000 Subject: [PATCH 3/8] Import the API by domain directory and register routes per domain main.osl now composes the app: it imports each src/ directory, runs startup, builds the router and calls one register function per domain. Configuration moves to src/config, each global cache moves next to the code that uses it, and each domain's routes move to its own routes.osl in their original order, with the same paths, methods, middleware and limits. opal.json marks the project root so tests can import across directories. r2Client is declared as *s3.Client because an auto global loses its type in other files. --- main.osl | 644 ++---------------------- opal.json | 13 + scripts/route_dump.py | 2 +- src/admin/admin.osl | 3 + src/admin/errors.test.osl | 2 +- src/admin/project-moderation.osl | 2 + src/admin/routes.osl | 35 ++ src/admin/site-error-origins.osl | 7 + src/auth/auth.osl | 2 + src/auth/editor-session-policy.test.osl | 2 +- src/auth/privacy-policy.test.osl | 2 +- src/auth/routes.osl | 28 ++ src/auth/user-identity.osl | 3 + src/classroom/classroom-switch.test.osl | 2 +- src/classroom/classrooms.osl | 2 + src/classroom/routes.osl | 57 +++ src/community/development.osl | 2 + src/community/groups.osl | 2 + src/community/like-milestones.test.osl | 1 - src/community/remote-milestones.osl | 2 + src/community/routes.osl | 138 +++++ src/config/config.osl | 160 ++++++ src/games/game-data.osl | 2 + src/games/game-inventory.test.osl | 2 +- src/games/routes.osl | 21 + src/git/git-inspection.osl | 2 + src/git/routes.osl | 22 + src/history/routes.osl | 5 + src/projects/projects.osl | 4 + src/projects/routes.osl | 60 +++ src/realtime/game-realtime.osl | 3 + src/rotur/perks.osl | 4 + src/rotur/rotur-payments.osl | 2 + src/rotur/rotur-payments.test.osl | 1 - src/rotur/rotur-signals.osl | 2 + src/rotur/rotur-signals.test.osl | 1 - src/rotur/routes.osl | 7 + src/storage/routes.osl | 24 + src/storage/storage-ledger.test.osl | 2 +- 39 files changed, 649 insertions(+), 626 deletions(-) create mode 100644 opal.json create mode 100644 src/admin/routes.osl create mode 100644 src/admin/site-error-origins.osl create mode 100644 src/auth/routes.osl create mode 100644 src/classroom/routes.osl create mode 100644 src/community/routes.osl create mode 100644 src/config/config.osl create mode 100644 src/games/routes.osl create mode 100644 src/git/routes.osl create mode 100644 src/history/routes.osl create mode 100644 src/projects/routes.osl create mode 100644 src/rotur/routes.osl create mode 100644 src/storage/routes.osl diff --git a/main.osl b/main.osl index 4d75e8c..a06b1ff 100644 --- a/main.osl +++ b/main.osl @@ -21,289 +21,20 @@ import "go:mistwarp.local/r2inventory" import "go:mistwarp.local/storagefs" import "go:mistwarp.local/sharecard" -def dotenvValue(string name) string ( - if !fs.exists(".env") ( - return "" - ) - string contents = fs.readFile(".env") catch (err) ( - log "WARNING: could not read .env: " ++ err.toStr() - return "" - ) - array lines = contents.split("\n") - for i lines.len ( - string line = lines[i].toStr().strip() - if line.startsWith("export ") ( - line = line.replace("export ", "").strip() - ) - if line != "" and !line.startsWith("#") and line.contains("=") ( - array parts = line.split("=") - string key = parts[1].toStr().strip() - if key == name ( - string value = parts[2].toStr().strip() - for p parts.len ( - if p > 2 ( - value ++= "=" ++ parts[p].toStr() - ) - ) - return value.replace("\"", "").replace("'", "") - ) - ) - ) - return "" -) - -def envValue(string name) string ( - string value = env.get(name, "") catch (err) ( - log "WARNING: could not read " ++ name ++ " from the environment: " ++ err.toStr() - return "" - ) - return value -) - -// Configuration is looked up in this order: -// 1. MISTWARP_ in the real environment (an explicit, app-specific override) -// 2. this app's .env -// 3. in the real environment (for deployments that don't ship a .env) -// -// Inherited variables come last on purpose. The API is often started from a -// shared shell that exports another project's variables (for example Rotur's -// R2_ACCESS_KEY_ID and R2_PUBLIC_BASE), and those must not silently replace -// this service's own configuration. -def configValue(string name, string defaultValue) string ( - string value = envValue("MISTWARP_" ++ name) - if value != "" ( - return value - ) - value = dotenvValue(name) - if value != "" ( - return value - ) - value = envValue(name) - if value != "" ( - return value - ) - return defaultValue -) - -string projectsDir = "data/projects/" -string indexFile = "data/projects-index.json" -string assetsIndexFile = "data/assets-index.json" -string usersDir = "data/users/" -string sessionsDir = "data/sessions/" -string commentsDir = "data/comments/" -string reportsDir = "data/reports/" -string reportsFile = reportsDir ++ "reports.json" -string reportEvidenceDir = "data/report-evidence/" -string newsFile = "data/news.json" -string notificationsDir = "data/notifications/" -string activityDir = "data/activity/" -string userSettingsDir = "data/usersettings/" -string tmpDir = "data/tmp/" -string stagingDir = "data/staging/" -string thumbnailsDir = "data/thumbnails/" -string cardsDir = "data/cards/" -string thumbnailSecretFile = "data/thumbnail-secret" -string quotaDir = "data/quota/" -string userIdsFile = "data/userids.json" -string adminsFile = "data/admins.json" -string bansFile = "data/bans.json" -string shadowBansFile = "data/shadowbans.json" -string agreementAcceptDir = "data/agreement-acceptances/" -string extensionsDir = "data/extensions/" -string releasesDir = "data/releases/" -string extensionsFile = "data/extensions.json" -string safetyDir = "data/safety/" -string trashDir = "data/trash/" -string gameSavesDir = "data/game-saves/" -string gameSaveUsageDir = "data/game-save-usage/" -string legacyAccountGameDataDir = "data/account-game-data/" -string gameEntitlementsDir = "data/game-entitlements/" -string gameInventoryDir = "data/game-inventory/" -string backpackDir = "data/backpack/" - -number maxWorkspaceBytes = 536870912 -number maxWorkspaceFiles = 1000000 -number maxUploadChunkBytes = 8388608 - -number maxProjectJsonBytes = 268435456 -number maxStoredProjectJsonBytes = 20971520 -number maxThumbBytes = 1048576 -number maxAssetCount = 2000 -number quotaWindowMs = 604800000 -number uploadDebounceMs = 604800000 -number maxProjectExtensions = 100 -number maxExtensionSourceBytes = 2097152 -number maxProjectExtensionBytes = 10485760 - -string appURL = configValue("APP_URL", "https://api.mistwarp.org") -string port = configValue("PORT", "5627") -string roturAppKey = configValue("ROTUR_APP_KEY", "mistwarp") -string r2Endpoint = configValue("R2_ENDPOINT", "") -string r2Bucket = configValue("R2_BUCKET", "mistwarp") -string r2AccessKeyId = configValue("R2_ACCESS_KEY_ID", "") -string r2SecretAccessKey = configValue("R2_SECRET_ACCESS_KEY", "") -string r2PublicBase = configValue("R2_PUBLIC_BASE", "") -string giteaURL = configValue("GITEA_URL", "https://git.rotur.dev") -string giteaAdminToken = configValue("GITEA_ADMIN_TOKEN", "") -string githubOrg = configValue("GITHUB_ORG", "MistWarp") -string githubToken = configValue("GITHUB_TOKEN", "") -string editorOrigin = configValue("EDITOR_ORIGIN", "https://mistwarp.org") -array adminUsers = configValue("ADMIN_USERS", "mist").toLower().split(",") -string mistwarpRoturToken = configValue("MISTWARP_ROTUR_TOKEN", "") -string mistwarpRoturUser = configValue("MISTWARP_ROTUR_USER", "") -string roturBase = configValue("ROTUR_BASE", "https://api.rotur.dev") -string commerceServiceKey = configValue("COMMERCE_SERVICE_KEY", "") -string stripeSecretKey = configValue("STRIPE_SECRET_KEY", "") -string stripeWebhookSecret = configValue("STRIPE_WEBHOOK_SECRET", "") -string stripeClassroomPrice = configValue("STRIPE_CLASSROOM_PRICE", "") -string stripeSeatPackPrice = configValue("STRIPE_SEAT_PACK_PRICE", "") -string stripeClassroomPriceYearly = configValue("STRIPE_CLASSROOM_PRICE_YEARLY", "") -string stripeSeatPackPriceYearly = configValue("STRIPE_SEAT_PACK_PRICE_YEARLY", "") -string realtimeURL = configValue("REALTIME_URL", "wss://api.mistwarp.org/v1/connect") -number historyMigrationWorkers = configValue("HISTORY_MIGRATION_WORKERS", "4").toNum().toInt() -if historyMigrationWorkers < 1 ( - historyMigrationWorkers = 1 -) -if historyMigrationWorkers > 8 ( - historyMigrationWorkers = 8 -) -string purchaseKeysFile = "data/purchase-keys.json" - -boolean r2Local = r2Endpoint == "" or r2AccessKeyId == "" -string localBlobsDir = "data/blobs/" - -auto r2Client = s3.new({ - "endpoint": if r2Local then "http://127.0.0.1:9" else r2Endpoint, - "bucket": r2Bucket, - "access_key_id": r2AccessKeyId, - "secret_access_key": r2SecretAccessKey, - "region": "auto" -}) - -void fs.mkdir("data/", {recursive: true}) - -import "./utils.osl" -import "./request-body.osl" -import "./search-ranking.osl" -import "./request-limits.osl" -import "./project-sharing-policy.osl" -import "./archive-security.osl" -import "./storage-addressing.osl" -import "./storage.osl" -import "./database.osl" -import "./user-identity.osl" -import "./asset-routing.osl" -import "./thumbnail-access.osl" -import "./r2.osl" -import "./r2-upload-queue.osl" -import "./cache-policy.osl" -import "./git-inspection.osl" -import "./gitea.osl" -import "./rotur-app.osl" -import "./rotur-payments.osl" -import "./rotur-signals.osl" -import "./rotur-badges.osl" -import "./admin-user-policy.osl" -import "./admin-users.osl" -import "./rotur-plan.osl" -import "./auth.osl" -import "./middleware.osl" -import "./upgrade-policy.osl" -import "./perks.osl" -import "./vanity-policy.osl" -import "./quota-policy.osl" -import "./storage-ledger-policy.osl" -import "./storage-ledger.osl" -import "./groups.osl" -import "./banned-owners.osl" -import "./related-projects.osl" -import "./random-projects.osl" -import "./projects.osl" -import "./storage-usage.osl" -import "./live-collaboration.osl" -import "./history-delta.osl" -import "./commit-management.osl" -import "./game-data.osl" -import "./game-realtime.osl" -import "./extensions.osl" -import "./monetize.osl" -import "./game-inventory.osl" -import "./backpack-policy.osl" -import "./backpack.osl" -import "./game-marketplace.osl" -import "./report-evidence.osl" -import "./comments.osl" -import "./bounties.osl" -import "./social.osl" -import "./pull-metadata.osl" -import "./pulls.osl" -import "./reports.osl" -import "./errors.osl" -array errorReportOrigins = [] -for entry in (editorOrigin ++ "," ++ configValue("ERROR_REPORT_ORIGINS", "")).split(",") ( - string normalized = siteErrorOrigin(entry, "") - if normalized != "" ( - errorReportOrigins.append(normalized) - ) -) -import "./admin.osl" -import "./funnel-events-policy.osl" -import "./funnel-events.osl" -import "./project-moderation.osl" -import "./like-milestone-policy.osl" -import "./like-milestones.osl" -import "./notifications.osl" -import "./remote-milestones.osl" -import "./news.osl" -import "./development-links.osl" -import "./development.osl" -import "./standing.osl" -import "./settings.osl" -import "./agreement.osl" -import "./account-controls.osl" -import "./rotur-webhooks.osl" -import "./challenge-ranking.osl" -import "./community-tools.osl" -import "./classroom-policy.osl" -import "./release-access-policy.osl" -import "./editor-session-policy.osl" -import "./privacy-policy.osl" -import "./classrooms.osl" -import "./classroom-billing-policy.osl" -import "./classroom-billing.osl" -import "./classroom-groups.osl" -import "./classroom-release.osl" -import "./classroom-schools.osl" -import "./classroom-present.osl" -import "./classroom-legal-policy.osl" -import "./classroom-legal.osl" -import "./classroom-switch-policy.osl" -import "./classroom-switch.osl" -import "./site-realtime.osl" -import "./history-migration.osl" - -auto remoteMilestoneChecks = cache.new({capacity: 10000, ttl: 10000}) -auto viewDedup = cache.new({capacity: 100000, ttl: 21600000}) -auto roturAppOwnerCache = cache.new({capacity: 4, ttl: 3600000}) -auto viewIndexFlush = cache.new({capacity: 100000, ttl: 600000}) -auto userIdsCache = cache.new({capacity: 10000, ttl: 300000}) -auto identityRefreshCache = cache.new({capacity: 10000, ttl: 300000}) -auto sessionCache = cache.new({capacity: 10000, ttl: 3600000}) -auto bansCache = cache.new({capacity: 2, ttl: 300000}) -auto shadowBansCache = cache.new({capacity: 2, ttl: 300000}) -auto adminsCache = cache.new({capacity: 2, ttl: 300000}) -auto sharedProjectsCache = cache.new({capacity: 2, ttl: 60000}) -auto gitInspectionPruneCache = cache.new({capacity: 4, ttl: 300000}) -auto paidPerksCache = cache.new({capacity: 10000, ttl: 300000}) -auto lastKnownTiers = cache.new({capacity: 10000, ttl: 2592000000}) -auto roturTierUnreachable = cache.new({capacity: 10000, ttl: 60000}) -auto roturMessageSignals = cache.new({capacity: 10000, ttl: 60000}) -auto groupSnapshotCache = cache.new({capacity: 10000, ttl: 60000}) -auto gameDataCapabilityCache = cache.new({capacity: 100000, ttl: 300000}) -auto multiplayerTicketCache = cache.new({capacity: 100000, ttl: 120000}) -auto realtimeHandshakeCache = cache.new({capacity: 100000, ttl: 30000}) -auto developmentCache = cache.new({capacity: 4, ttl: 300000}) -auto classroomPoolCache = cache.new({capacity: 10000, ttl: 60000}) +import "./src/config" +import "./src/core" +import "./src/http" +import "./src/storage" +import "./src/git" +import "./src/rotur" +import "./src/auth" +import "./src/projects" +import "./src/history" +import "./src/games" +import "./src/realtime" +import "./src/community" +import "./src/admin" +import "./src/classroom" ensureDirectories() string thumbnailSecret = loadThumbnailSecret(thumbnailSecretFile) @@ -400,15 +131,7 @@ app.use(realtimeUpgradeGuard) app.use(serve.corsOpen()) app.use(serve.logger()) -app.GET("/thumbnails/:file", handleGetThumbnail) -app.GET("/blobs/assets/:name", handleGetAssetBlob) -app.GET("/blobs/backpack/:folder/:file", handleGetBackpackBlob) -app.GET("/blobs/projects/:id/project.json", handleGetProjectBlob) -app.GET("/blobs/evidence/:id/project.json", handleGetEvidenceJson) -app.GET("/blobs/evidence/:id/assets/:name", handleGetEvidenceAsset) -if r2Local ( - app.static("/blobs", "./data/blobs") -) +registerBlobRoutes(app) app.use(authMiddleware) app.use(editorSessionGuard) @@ -418,335 +141,24 @@ setupGameRealtime() setupSiteRealtime() def setupUploadRoutes(*serve.Router uploads) ( - uploads.POST("/projects/:id/upload", serve.maxBodySize(maxProjectUploadRequestBytes), requireAuth, requireCurrentAgreement, handleUploadProject) - uploads.POST("/projects/:id/upload-parts/:token/chunk", serve.maxBodySize(maxUploadChunkRequestBytes), requireAuth, requireCurrentAgreement, handleUploadProjectPartChunk) - uploads.POST("/projects/:id/history/bootstrap", serve.maxBodySize(maxWorkspaceBootstrapRequestBytes), requireAuth, requireCurrentAgreement, handleBootstrapProjectHistory) - uploads.POST("/projects/:id/thumbnail", serve.maxBodySize(maxThumbnailRequestBytes), requireAuth, handleSetThumbnail) - uploads.POST("/me/backpack", serve.maxBodySize(maxBackpackRequestBytes), requireAuth, requireCurrentAgreement, handleCreateBackpackItem) - uploads.POST("/spaces/:id/thumbnail", serve.maxBodySize(maxThumbnailRequestBytes), requireAuth, handleSetSpaceThumbnail) - uploads.POST("/admin/projects/:id/extensions/index", serve.maxBodySize(maxExtensionIndexRequestBytes), requireAdmin, handleAdminIndexProjectExtensions) + registerProjectUploadRoutes(uploads) + registerStorageUploadRoutes(uploads) + registerCommunityUploadRoutes(uploads) ) def setupApiRoutes(*serve.Router api) ( api.use(serve.maxBodySize(maxStandardRequestBytes)) - api.GET("/health", handleHealth) - api.GET("/health/dependencies", handleDependencyHealth) - api.GET("/perks", handleGetPerks) - api.POST("/perks/refresh", requireAuth, handleRefreshPerks) - api.POST("/perks/seen", requireAuth, handlePerksSeen) - api.POST("/auth", handleRoturAuth) - api.POST("/auth/editor-session", serve.limitRequests(600, {window: 3600000}), requireAuth, handleCreateEditorSession) - api.GET("/me", handleGetMe) - api.POST("/logout", handleLogout) - - api.POST("/milestones/followers/:name", requireAuth, handleCheckFollowerMilestones) - api.POST("/milestones/posts/:id", requireAuth, handleCheckPostMilestones) - api.POST("/milestones/themes/:id", requireAuth, handleCheckThemeMilestones) - api.GET("/notifications", requireAuth, handleGetNotifications) - api.POST("/notifications/read", requireAuth, handleReadNotifications) - - api.GET("/me/quota", requireAuth, handleGetMyQuota) - api.GET("/me/stats", requireAuth, handleGetMyStats) - api.GET("/me/library", requireAuth, handleGetMyLibrary) - api.GET("/me/shared-projects", requireAuth, handleGetSharedProjects) - api.PUT("/me/library/:project/visibility", requireAuth, handleSetLibraryProjectVisibility) - api.GET("/me/trash", requireAuth, handleGetMyTrash) - api.GET("/me/purchases", requireAuth, handleGetMyPurchases) - api.GET("/me/settings", requireAuth, handleGetMySettings) - api.PUT("/me/settings", requireAuth, handlePutMySettings) - api.GET("/me/game-saves", requireAuth, handleListMyGameSaves) - api.GET("/me/game-inventory", requireAuth, handleGetMyGameInventory) - api.GET("/me/backpack", requireAuth, handleGetMyBackpack) - api.PATCH("/me/backpack/:id", requireAuth, handleUpdateBackpackItem) - api.DELETE("/me/backpack/:id", requireAuth, handleDeleteBackpackItem) - api.POST("/me/backpack/delete", requireAuth, handleDeleteBackpackItems) - api.DELETE("/me/game-saves/:project", requireAuth, handleDeleteMyGameSave) - api.GET("/me/export", requireAuth, handleExportMyData) - api.DELETE("/me/data", requireAuth, handleDeleteMyData) - api.POST("/rotur/webhook", handleRoturWebhook) - api.GET("/me/safety", requireAuth, handleGetMySafety) - api.POST("/me/blocks/:name", requireAuth, handleBlockUser) - api.DELETE("/me/blocks/:name", requireAuth, handleUnblockUser) - api.POST("/me/mutes/:name", requireAuth, handleMuteUser) - api.DELETE("/me/mutes/:name", requireAuth, handleUnmuteUser) - - api.GET("/agreement", handleGetAgreement) - api.POST("/agreement/accept", requireAuth, handleAcceptAgreement) - - api.GET("/explore", handleExplore) - api.GET("/vanity/:slug", handleResolveVanityProject) - api.GET("/groups/:tag", handleGetMistWarpGroup) - api.GET("/leaderboard", handleLeaderboard) - api.GET("/development/pulls", handleDevelopmentPulls) - api.GET("/roadmap", handleListIdeas) - api.POST("/roadmap", requireAuth, requireGoodStanding, handleCreateIdea) - api.POST("/roadmap/:id/vote", requireAuth, handleVoteIdea) - api.PUT("/roadmap/:id", requireAdmin, handleUpdateIdea) - api.GET("/roadmap/:id/comments", handleGetIdeaComments) - api.POST("/roadmap/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateIdeaComment) - api.DELETE("/roadmap/:id/comments/:cid", requireAuth, handleDeleteIdeaComment) - api.PUT("/roadmap/:id/comments/:cid", requireAuth, handleEditIdeaComment) - api.POST("/roadmap/:id/comments/:cid/react", requireAuth, handleReactIdeaComment) - api.GET("/spaces", handleListSpaces) - api.POST("/spaces", requireAuth, requireGoodStanding, handleCreateSpace) - api.GET("/spaces/:id", handleGetSpace) - api.GET("/spaces/:id/manage", requireAuth, handleGetSpaceManagement) - api.PUT("/spaces/:id", requireAuth, handleUpdateSpace) - api.DELETE("/spaces/:id", requireAuth, handleDeleteSpace) - api.POST("/spaces/:id/projects/:project", requireAuth, handleAddSpaceProject) - api.DELETE("/spaces/:id/projects/:project", requireAuth, handleRemoveSpaceProject) - api.POST("/spaces/:id/follow", requireAuth, handleFollowSpace) - api.DELETE("/spaces/:id/follow", requireAuth, handleUnfollowSpace) - api.POST("/spaces/:id/curators", requireAuth, handleInviteSpaceCurator) - api.DELETE("/spaces/:id/curators/:username", requireAuth, handleRemoveSpaceCurator) - api.POST("/spaces/:id/invitations/respond", requireAuth, handleRespondSpaceCuratorInvite) - api.DELETE("/spaces/:id/invitations/:username", requireAuth, handleCancelSpaceCuratorInvite) - api.POST("/spaces/:id/judges", requireAuth, handleInviteChallengeJudge) - api.DELETE("/spaces/:id/judges/:username", requireAuth, handleRemoveChallengeJudge) - api.POST("/spaces/:id/judge-invitations/respond", requireAuth, handleRespondChallengeJudgeInvite) - api.POST("/spaces/:id/join", requireAuth, requireGoodStanding, handleJoinChallenge) - api.DELETE("/spaces/:id/join", requireAuth, handleLeaveChallenge) - api.PUT("/spaces/:id/entries/:project/score", requireAuth, handleScoreChallengeEntry) - api.PUT("/spaces/:id/entries/:project/vote", requireAuth, handleVoteChallengeEntry) - api.POST("/spaces/:id/results", requireAuth, handlePublishChallengeResults) - api.POST("/spaces/:id/react", requireAuth, handleReactSpace) - api.GET("/spaces/:id/comments", handleGetSpaceComments) - api.POST("/spaces/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateSpaceComment) - api.DELETE("/spaces/:id/comments/:cid", requireAuth, handleDeleteSpaceComment) - api.PUT("/spaces/:id/comments/:cid", requireAuth, handleEditSpaceComment) - api.POST("/spaces/:id/comments/:cid/react", requireAuth, handleReactSpaceComment) - api.POST("/spaces/:id/comments/:cid/pin", requireAuth, handlePinSpaceComment) - api.GET("/me/spaces", requireAuth, handleMySpaces) - - api.GET("/classroom/join/:code", serve.limitRequests(30, {window: 60000}), handleClassroomJoinInfo) - api.POST("/classroom/login", serve.limitRequests(20, {window: 60000}), handleClassroomLogin) - api.GET("/classroom/available", handleClassroomAvailable) - api.GET("/classroom", requireAuth, handleGetClassroom) - api.POST("/classroom/terms/accept", requireClassroomTeacher, serve.limitRequests(20, {window: 3600000}), handleAcceptClassroomTerms) - api.GET("/classroom/school", requireClassroomTeacher, handleGetSchool) - api.GET("/classroom/school/audit", requireClassroomTeacher, handleGetSchoolAudit) - api.GET("/classroom/school/export", requireClassroomTeacher, serve.limitRequests(10, {window: 3600000}), handleExportSchool) - api.POST("/classroom/school/teachers", requireClassroomTeacher, requireClassroomTerms, handleAddSchoolTeacher) - api.DELETE("/classroom/school/teachers/:teacher", requireClassroomTeacher, handleRemoveSchoolTeacher) - api.PUT("/classroom/school/teachers/:teacher/admin", requireClassroomTeacher, handleSetSchoolAdmin) - api.POST("/classroom/billing/checkout", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(20, {window: 3600000}), handleClassroomCheckout) - api.POST("/classroom/billing/portal", requireClassroomTeacher, serve.limitRequests(30, {window: 3600000}), handleClassroomBillingPortal) - api.POST("/classroom/billing/webhook", handleStripeWebhook) - api.POST("/classroom/trial", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(10, {window: 3600000}), handleStartClassroomTrial) - api.POST("/classroom/classes", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(20, {window: 3600000}), handleCreateClass) - api.GET("/classroom/classes/:id", requireClassroomTeacher, handleGetClass) - api.PUT("/classroom/classes/:id", requireClassroomTeacher, handleUpdateClass) - api.DELETE("/classroom/classes/:id", requireClassroomTeacher, handleDeleteClass) - api.POST("/classroom/classes/:id/code", requireClassroomTeacher, handleResetClassCode) - api.POST("/classroom/classes/:id/teachers", requireClassroomTeacher, requireClassroomTerms, handleAddCoTeacher) - api.DELETE("/classroom/classes/:id/teachers/:teacher", requireClassroomTeacher, handleRemoveCoTeacher) - api.POST("/classroom/classes/:id/transfer", requireClassroomTeacher, handleTransferClass) - api.GET("/classroom/classes/:id/audit", requireClassroomTeacher, handleGetClassAudit) - api.GET("/classroom/classes/:id/export", requireClassroomTeacher, serve.limitRequests(30, {window: 3600000}), handleExportClass) - api.GET("/classroom/classes/:id/groups", requireClassroomTeacher, handleListClassGroups) - api.POST("/classroom/classes/:id/present", requireClassroomTeacher, requireClassroomTerms, handleStartPresentation) - api.DELETE("/classroom/classes/:id/present", requireClassroomTeacher, handleStopPresentation) - api.POST("/classroom/classes/:id/groups", requireClassroomTeacher, requireClassroomTerms, handleCreateClassGroup) - api.DELETE("/classroom/classes/:id/groups/:gid", requireClassroomTeacher, handleDeleteClassGroup) - api.POST("/classroom/classes/:id/students", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(30, {window: 3600000}), handleCreateStudents) - api.GET("/classroom/classes/:id/students/:sid", requireClassroomTeacher, handleGetClassStudent) - api.GET("/classroom/classes/:id/students/:sid/export", requireClassroomTeacher, serve.limitRequests(60, {window: 3600000}), handleExportClassStudent) - api.PUT("/classroom/classes/:id/students/:sid", requireClassroomTeacher, handleUpdateClassStudent) - api.DELETE("/classroom/classes/:id/students/:sid", requireClassroomTeacher, handleDeleteClassStudent) - api.POST("/classroom/classes/:id/students/:sid/reset", requireClassroomTeacher, handleResetStudentLogin) - api.POST("/classroom/classes/:id/students/:sid/move", requireClassroomTeacher, handleMoveClassStudent) - api.POST("/classroom/classes/:id/students/:sid/release", requireClassroomTeacher, requireClassroomTerms, handleReleaseStudent) - api.DELETE("/classroom/classes/:id/students/:sid/release", requireClassroomTeacher, handleCancelStudentRelease) - api.GET("/classroom/claim/:code", serve.limitRequests(30, {window: 60000}), handleGetClaim) - api.POST("/classroom/claim/:code", requireAuth, serve.limitRequests(10, {window: 60000}), handleRedeemClaim) - api.POST("/classroom/classes/:id/assignments", requireClassroomTeacher, requireClassroomTerms, handleCreateAssignment) - api.GET("/classroom/classes/:id/assignments/:aid", requireClassroomTeacher, handleGetAssignment) - api.PUT("/classroom/classes/:id/assignments/:aid", requireClassroomTeacher, handleUpdateAssignment) - api.DELETE("/classroom/classes/:id/assignments/:aid", requireClassroomTeacher, handleDeleteAssignment) - api.PUT("/classroom/classes/:id/assignments/:aid/submissions/:sid", requireClassroomTeacher, handleReviewSubmission) - api.POST("/classroom/assignments/:aid/start", requireAuth, handleStartAssignment) - api.POST("/classroom/assignments/:aid/turn-in", requireAuth, handleTurnInAssignment) - api.POST("/classroom/assignments/:aid/unsubmit", requireAuth, handleUnsubmitAssignment) - - api.POST("/projects", requireAuth, serve.limitRequests(30, {window: 3600000}), handleCreateProject) - api.POST("/projects/:id/live", requireAuth, serve.limitRequests(120, {window: 60000}), handleLiveCollaboration) - api.POST("/projects/:id/assets/check", requireAuth, handleCheckProjectAssets) - api.POST("/projects/:id/upload-parts", requireAuth, requireCurrentAgreement, handleCreateProjectUploadPart) - api.POST("/projects/:id/upload-parts/:token/complete", requireAuth, requireCurrentAgreement, handleCompleteProjectUploadPart) - api.GET("/projects/random", handleRandomProject) - api.GET("/projects/featured", handleFeaturedProject) - api.GET("/projects/:id/related", handleRelatedProjects) - api.GET("/sitemap.xml", handleSitemap) - api.GET("/projects/:id/project.json", handleGetProjectJson) - api.GET("/projects/:id/trusted-extensions", handleGetProjectTrustedExtensions) - api.GET("/projects/:id/workspace.mwp", handleGetProjectWorkspace) - api.GET("/projects/:id/extensions/:hash/source", handleGetProjectExtensionSource) - api.GET("/projects/:id/editor", handleGetEditorProject) - api.GET("/projects/:id", handleGetProject) - api.POST("/projects/:id/data-capability", requireAuth, handleCreateGameDataCapability) - api.POST("/projects/:id/multiplayer-ticket", requireAuth, handleCreateMultiplayerTicket) - api.GET("/projects/:id/products", handleListGameProducts) - api.GET("/projects/:id/products/:product/owns", requireAuth, handleOwnsGameProduct) - api.POST("/projects/:id/products/:product/purchase/intent", requireAuth, handleGameProductIntent) - api.POST("/projects/:id/products/:product/purchase/confirm", requireAuth, handleGameProductConfirm) - api.POST("/projects/:id/products/:product/grant", requireAuth, handleGrantGameProduct) - api.POST("/projects/:id/products/:product/revoke", requireAuth, handleRevokeGameProduct) - api.POST("/projects/:id/products/:product/refund/intent", requireAuth, handleGameProductRefundIntent) - api.GET("/projects/:id/game-inventory-config", requireAuth, handleGetGameInventoryConfig) - api.GET("/projects/:id/me/inventory", requireAuth, handleGetProjectInventory) - api.POST("/projects/:id/me/inventory/grant", requireAuth, handleGrantProjectItem) - api.GET("/projects/:id/me/save", requireAuth, handleGetMyProjectSave) - api.PUT("/projects/:id/me/save", requireAuth, handlePutMyProjectSave) - api.GET("/projects/:id/card.svg", handleProjectCard) - api.GET("/projects/:id/card.png", handleProjectSharePng) - api.PUT("/projects/:id", requireAuth, handleUpdateProject) - api.DELETE("/projects/:id", requireAuth, handleDeleteProject) - api.POST("/projects/:id/restore", requireAuth, handleRestoreProject) - api.DELETE("/trash/:id", requireAuth, handlePurgeTrashedProject) - api.POST("/projects/:id/publish", requireAuth, requireGoodStanding, handlePublishProject) - api.POST("/projects/:id/unpublish", requireAuth, handleUnpublishProject) - api.POST("/projects/:id/visibility", requireAuth, requireGoodStanding, handleSetVisibility) - api.POST("/projects/:id/purchase/intent", handleProjectSalesEnded) - api.POST("/projects/:id/purchase/confirm", handleProjectSalesEnded) - api.POST("/projects/:id/view", handleViewProject) - api.POST("/projects/:id/save", requireAuth, handleSaveProject) - api.DELETE("/projects/:id/save", requireAuth, handleUnsaveProject) - api.POST("/projects/:id/react", requireAuth, handleReactProject) - api.GET("/projects/:id/remixes", handleGetRemixes) - api.GET("/projects/:id/remixtree", handleGetRemixTree) - api.POST("/projects/:id/remix", requireAuth, serve.limitRequests(30, {window: 3600000}), handleRemixProject) - api.POST("/projects/:id/preview", requireAuth, serve.limitRequests(30, {window: 3600000}), handleCreatePreview) - api.GET("/projects/:id/releases", handleListReleases) - api.GET("/projects/:id/releases/:release/project.json", handleGetReleaseProjectJson) - api.POST("/projects/:id/releases", requireAuth, serve.limitRequests(30, {window: 3600000}), handleCreateRelease) - api.DELETE("/projects/:id/releases/:release", requireAuth, handleDeleteRelease) - api.POST("/projects/:id/contribute", requireAuth, handleCreateContribution) - api.POST("/projects/:id/diagnostics", serve.limitRequests(120, {window: 60000}), handleRecordDiagnostic) - api.GET("/projects/:id/diagnostics", requireAuth, handleGetDiagnostics) - api.POST("/projects/:id/feedback", requireAuth, requireGoodStanding, serve.limitRequests(20, {window: 3600000}), handleCreateProjectFeedback) - api.GET("/projects/:id/feedback", requireAuth, handleGetProjectFeedback) - api.PUT("/projects/:id/feedback/:feedback", requireAuth, handleUpdateProjectFeedback) - api.GET("/projects/:id/reviews", handleListProjectReviews) - api.PUT("/projects/:id/reviews/me", requireAuth, requireGoodStanding, handleSaveProjectReview) - api.DELETE("/projects/:id/reviews/me", requireAuth, handleDeleteProjectReview) - - api.GET("/projects/:id/comments", handleGetProjectComments) - api.POST("/projects/:id/comments/donation/intent", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleProjectCommentDonationIntent) - api.POST("/projects/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateProjectComment) - api.DELETE("/projects/:id/comments/:cid", requireAuth, handleDeleteProjectComment) - api.PUT("/projects/:id/comments/:cid", requireAuth, handleEditProjectComment) - api.POST("/projects/:id/comments/:cid/react", requireAuth, handleReactProjectComment) - api.POST("/projects/:id/comments/:cid/pin", requireAuth, handlePinProjectComment) - - api.GET("/bounties/:id/activity", handleGetBountyActivity) - api.POST("/bounties/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateBountyComment) - api.PUT("/bounties/:id/comments/:cid", requireAuth, handleEditBountyComment) - api.DELETE("/bounties/:id/comments/:cid", requireAuth, handleDeleteBountyComment) - api.POST("/bounties/:id/workers/me", requireAuth, requireGoodStanding, handleJoinBounty) - api.DELETE("/bounties/:id/workers/me", requireAuth, handleLeaveBounty) - - api.GET("/projects/:id/commits", handleGetCommits) - api.GET("/projects/:id/branches", handleListProjectBranches) - api.POST("/projects/:id/branches", requireAuth, handleCreateProjectBranch) - api.PATCH("/projects/:id/branches", requireAuth, handleRenameProjectBranch) - api.DELETE("/projects/:id/branches", requireAuth, handleDeleteProjectBranch) - api.POST("/projects/:id/branches/merge", requireAuth, handlePrepareBranchMerge) - api.GET("/projects/:id/commits/:sha", handleGetCommitInspection) - api.PATCH("/projects/:id/commits/:sha", requireAuth, handlePatchCommit) - api.GET("/projects/:id/commits/:sha/co-authors", handleGetCommitCollaborators) - api.GET("/projects/:id/commits/:sha/collaborators", handleGetCommitCollaborators) - api.GET("/projects/:id/commits/:sha/tree", handleGetCommitTree) - api.GET("/projects/:id/commits/:sha/file", handleGetCommitFile) - api.GET("/projects/:id/pulls", handleListPulls) - api.POST("/projects/:id/pulls", requireAuth, handleCreatePull) - api.GET("/projects/:id/pulls/:index", handleGetPull) - api.GET("/projects/:id/pulls/:index/timeline", handleGetPullTimeline) - api.POST("/projects/:id/pulls/:index/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreatePullComment) - api.PUT("/projects/:id/pulls/:index/comments/:cid", requireAuth, handleEditPullComment) - api.DELETE("/projects/:id/pulls/:index/comments/:cid", requireAuth, handleDeletePullComment) - api.GET("/projects/:id/pulls/:index/diff", requireAuth, handleGetPullDiff) - api.POST("/projects/:id/pulls/:index/merge", requireAuth, handleMergePull) - api.POST("/projects/:id/pulls/:index/close", requireAuth, handleClosePull) - - api.GET("/search/users", handleSearchUsers) - api.GET("/users/:name", handleGetUser) - api.GET("/users/:name/library", handleGetUserPlaytimeLibrary) - api.GET("/users/:name/reviews", handleListUserReviews) - api.PUT("/me/profile", requireAuth, handleUpdateProfile) - api.POST("/users/:name/follow", requireAuth, handleFollowUser) - api.DELETE("/users/:name/follow", requireAuth, handleUnfollowUser) - api.GET("/users/:name/projects", handleGetUserProjects) - api.GET("/activity", handleGetActivity) - api.GET("/users/:name/followers", handleGetUserFollowers) - api.GET("/users/:name/following", handleGetUserFollowing) - api.GET("/users/:name/comments", handleGetProfileComments) - api.POST("/users/:name/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateProfileComment) - api.DELETE("/users/:name/comments/:cid", requireAuth, handleDeleteProfileComment) - api.PUT("/users/:name/comments/:cid", requireAuth, handleEditProfileComment) - api.POST("/users/:name/comments/:cid/react", requireAuth, handleReactProfileComment) - api.POST("/users/:name/comments/:cid/pin", requireAuth, handlePinProfileComment) - - api.GET("/news", handleGetNews) - api.GET("/news/:id", handleGetNewsItem) - api.POST("/news/:id/view", serve.limitRequests(120, {window: 3600000}), handleViewNews) - api.POST("/news", requireAdmin, handleCreateNews) - api.PUT("/news/:id", requireAdmin, handleUpdateNews) - api.DELETE("/news/:id", requireAdmin, handleDeleteNews) - api.POST("/news/:id/react", requireAuth, handleReactNews) - api.POST("/news/:id/poll", requireAuth, handleVoteNewsPoll) - - api.POST("/report", requireAuth, serve.limitRequests(10, {window: 3600000}), handleCreateReport) - api.POST("/reports", requireAuth, serve.limitRequests(10, {window: 3600000}), handleCreateReport) - api.POST("/support", serve.limitRequests(5, {window: 3600000}), handleCreateSupportRequest) - api.POST("/errors", serve.limitRequests(20, {window: 60000}), handleRecordSiteError) - api.GET("/stats", handlePublicStats) - api.POST("/events", serve.limitRequests(120, {window: 60000}), handleRecordFunnelEvent) - - api.GET("/admin/reports", requireAdmin, handleGetReports) - api.GET("/admin/reports/evidence/:id", requireAdmin, handleGetReportEvidence) - api.POST("/admin/reports/resolve", requireAdmin, handleResolveReport) - api.POST("/admin/reports/action", requireAdmin, handleReportAction) - api.POST("/admin/reports/escalate", requireAdmin, handleEscalateReport) - api.GET("/admin/admins", requireAdmin, handleListAdmins) - api.POST("/admin/admins", requireAdmin, handleAddAdmin) - api.POST("/admin/admins/remove", requireAdmin, handleRemoveAdmin) - api.GET("/admin/bans", requireAdmin, handleListBans) - api.POST("/admin/ban", requireAdmin, handleBanUser) - api.POST("/admin/unban", requireAdmin, handleUnbanUser) - api.GET("/admin/shadowbans", requireAdmin, handleListShadowBans) - api.POST("/admin/shadowban", requireAdmin, handleShadowBanUser) - api.POST("/admin/unshadowban", requireAdmin, handleLiftShadowBan) - api.POST("/admin/projects/:id/hide", requireAdmin, handleHideProject) - api.POST("/admin/projects/:id/restore", requireAdmin, handleRestoreHiddenProject) - api.POST("/admin/projects/:id/shadowban", requireAdmin, handleShadowBanProject) - api.POST("/admin/projects/:id/unshadowban", requireAdmin, handleLiftProjectShadowBan) - api.POST("/admin/standing", requireAdmin, handleSetStanding) - api.GET("/admin/user", requireAdmin, handleGetUserAdmin) - api.POST("/admin/user/message", requireAdmin, handleMessageUser) - api.POST("/admin/user/profile", requireAdmin, handleAdminUpdateProfile) - api.POST("/admin/user/note", requireAdmin, handleSetAdminNote) - api.GET("/admin/projects", requireAdmin, handleAdminSearchProjects) - api.GET("/admin/stats", requireAdmin, handleAdminStats) - api.GET("/admin/storage", requireAdmin, handleAdminStorageStats) - api.POST("/admin/storage/sync", requireAdmin, handleAdminSyncR2StorageStats) - api.GET("/admin/users", requireAdmin, handleListUsers) - api.GET("/admin/agreement", requireAdmin, handleAdminGetAgreement) - api.POST("/admin/agreement", requireAdmin, handleAdminUpdateAgreement) - api.POST("/admin/reconcile-deletions", requireAdmin, handleReconcileDeletions) - api.GET("/admin/extensions", requireAdmin, handleAdminGetExtensions) - api.GET("/admin/errors", requireAdmin, handleAdminGetErrors) - api.POST("/admin/errors/resolve", requireAdmin, handleAdminResolveSiteError) - api.POST("/admin/errors/delete", requireAdmin, handleAdminDeleteSiteError) - api.POST("/admin/errors/delete-all", requireAdmin, handleAdminDeleteAllSiteErrors) - api.GET("/admin/extensions/:hash/source", requireAdmin, handleAdminGetExtensionSource) - api.POST("/admin/extensions/policy", requireAdmin, handleAdminSetExtensionPolicy) - api.POST("/admin/extensions/url-policy", requireAdmin, handleAdminSetExtensionUrlPolicy) - api.GET("/admin/classroom/plans", requireAdmin, handleAdminGetClassroomPlan) - api.POST("/admin/classroom/plans", requireAdmin, handleAdminSetClassroomPlan) - api.GET("/admin/classroom/schools", requireAdmin, handleAdminListSchools) - api.POST("/admin/classroom/schools", requireAdmin, handleAdminCreateSchool) - api.POST("/admin/classroom/purge", requireAdmin, handleAdminPurgeClassroom) + registerAuthRoutes(api) + registerRoturRoutes(api) + registerCommunityRoutes(api) + registerProjectRoutes(api) + registerGameRoutes(api) + registerStorageRoutes(api) + registerClassroomRoutes(api) + registerGitRoutes(api) + registerHistoryRoutes(api) + registerAdminRoutes(api) ) *serve.Router uploadsV1 = app.group("/v1") diff --git a/opal.json b/opal.json new file mode 100644 index 0000000..bee0a96 --- /dev/null +++ b/opal.json @@ -0,0 +1,13 @@ +{ + "name": "mistwarp-api", + "version": "1.0.0", + "main": "main.osl", + "scripts": { + "dev": ["osl", "run", "main.osl"], + "test": ["osl", "test"], + "check": ["osl", "lsp", "check", "main.osl"], + "build": ["osl", "compile", "main.osl", "-o", "main"], + "routes": ["python3", "scripts/route_dump.py", "."], + "smoke": ["python3", "scripts/e2e_smoke.py"] + } +} diff --git a/scripts/route_dump.py b/scripts/route_dump.py index f515c29..17c486d 100644 --- a/scripts/route_dump.py +++ b/scripts/route_dump.py @@ -96,7 +96,7 @@ def run(self, lines, routers, depth=0): for param, arg in zip(params, args): if arg in routers: scope[param] = routers[arg] - if any(re.search(r"\b(%s)\.\w+\(" % "|".join(map(re.escape, scope)), l) for l in body): + if any(arg in routers for arg in args) or any(re.search(r"\b(%s)\.\w+\(" % "|".join(map(re.escape, scope)), l) for l in body): self.run(body, scope, depth + 1) diff --git a/src/admin/admin.osl b/src/admin/admin.osl index 20f0aba..c77be4f 100644 --- a/src/admin/admin.osl +++ b/src/admin/admin.osl @@ -1,3 +1,6 @@ +auto bansCache = cache.new({capacity: 2, ttl: 300000}) +auto adminsCache = cache.new({capacity: 2, ttl: 300000}) + def loadAdmins() array ( auto res = readFile(adminsFile) if !res.ok ( diff --git a/src/admin/errors.test.osl b/src/admin/errors.test.osl index b321139..84e6b51 100644 --- a/src/admin/errors.test.osl +++ b/src/admin/errors.test.osl @@ -4,7 +4,7 @@ import "std:sync" import "std:serve" import "std:fs" import "std:regex" -import "./request-body.osl" +import "../http/request-body.osl" string path = "/tmp/mistwarp-errors-" ++ timestamp.toStr() ++ ".db" auto testDb = db.open(path) diff --git a/src/admin/project-moderation.osl b/src/admin/project-moderation.osl index d6f90ba..7ac2e61 100644 --- a/src/admin/project-moderation.osl +++ b/src/admin/project-moderation.osl @@ -1,3 +1,5 @@ +auto shadowBansCache = cache.new({capacity: 2, ttl: 300000}) + def loadShadowBans() object ( auto res = readFile(shadowBansFile) if !res.ok ( diff --git a/src/admin/routes.osl b/src/admin/routes.osl new file mode 100644 index 0000000..7418510 --- /dev/null +++ b/src/admin/routes.osl @@ -0,0 +1,35 @@ +def registerAdminRoutes(*serve.Router api) ( + api.POST("/errors", serve.limitRequests(20, {window: 60000}), handleRecordSiteError) + api.GET("/stats", handlePublicStats) + api.POST("/events", serve.limitRequests(120, {window: 60000}), handleRecordFunnelEvent) + + api.POST("/admin/reports/action", requireAdmin, handleReportAction) + + api.GET("/admin/admins", requireAdmin, handleListAdmins) + api.POST("/admin/admins", requireAdmin, handleAddAdmin) + api.POST("/admin/admins/remove", requireAdmin, handleRemoveAdmin) + api.GET("/admin/bans", requireAdmin, handleListBans) + api.POST("/admin/ban", requireAdmin, handleBanUser) + api.POST("/admin/unban", requireAdmin, handleUnbanUser) + api.GET("/admin/shadowbans", requireAdmin, handleListShadowBans) + api.POST("/admin/shadowban", requireAdmin, handleShadowBanUser) + api.POST("/admin/unshadowban", requireAdmin, handleLiftShadowBan) + api.POST("/admin/projects/:id/hide", requireAdmin, handleHideProject) + api.POST("/admin/projects/:id/restore", requireAdmin, handleRestoreHiddenProject) + api.POST("/admin/projects/:id/shadowban", requireAdmin, handleShadowBanProject) + api.POST("/admin/projects/:id/unshadowban", requireAdmin, handleLiftProjectShadowBan) + + api.GET("/admin/user", requireAdmin, handleGetUserAdmin) + + api.POST("/admin/user/note", requireAdmin, handleSetAdminNote) + api.GET("/admin/projects", requireAdmin, handleAdminSearchProjects) + api.GET("/admin/stats", requireAdmin, handleAdminStats) + api.GET("/admin/storage", requireAdmin, handleAdminStorageStats) + api.POST("/admin/storage/sync", requireAdmin, handleAdminSyncR2StorageStats) + api.GET("/admin/users", requireAdmin, handleListUsers) + + api.GET("/admin/errors", requireAdmin, handleAdminGetErrors) + api.POST("/admin/errors/resolve", requireAdmin, handleAdminResolveSiteError) + api.POST("/admin/errors/delete", requireAdmin, handleAdminDeleteSiteError) + api.POST("/admin/errors/delete-all", requireAdmin, handleAdminDeleteAllSiteErrors) +) diff --git a/src/admin/site-error-origins.osl b/src/admin/site-error-origins.osl new file mode 100644 index 0000000..7ac3062 --- /dev/null +++ b/src/admin/site-error-origins.osl @@ -0,0 +1,7 @@ +array errorReportOrigins = [] +for entry in (editorOrigin ++ "," ++ configValue("ERROR_REPORT_ORIGINS", "")).split(",") ( + string normalized = siteErrorOrigin(entry, "") + if normalized != "" ( + errorReportOrigins.append(normalized) + ) +) diff --git a/src/auth/auth.osl b/src/auth/auth.osl index 86762b2..6d5fa5d 100644 --- a/src/auth/auth.osl +++ b/src/auth/auth.osl @@ -1,3 +1,5 @@ +auto sessionCache = cache.new({capacity: 10000, ttl: 3600000}) + def setAuthCookie(*serve.Context c, string sessionId) ( c.setCookie("auth_token", sessionId, {maxAge: 604800000, path: "/", domain: "", secure: appURL.startsWith("https://"), httpOnly: true}) ) diff --git a/src/auth/editor-session-policy.test.osl b/src/auth/editor-session-policy.test.osl index c4d27a7..135a5ed 100644 --- a/src/auth/editor-session-policy.test.osl +++ b/src/auth/editor-session-policy.test.osl @@ -1,6 +1,6 @@ import "std:testing" import "std:regex" -import "./classroom-policy.osl" +import "../classroom/classroom-policy.osl" import "./editor-session-policy.osl" def projectHasStoredContent(object project) boolean ( diff --git a/src/auth/privacy-policy.test.osl b/src/auth/privacy-policy.test.osl index 8ebe582..db2dbec 100644 --- a/src/auth/privacy-policy.test.osl +++ b/src/auth/privacy-policy.test.osl @@ -1,6 +1,6 @@ import "std:testing" import "std:regex" -import "./classroom-policy.osl" +import "../classroom/classroom-policy.osl" import "./privacy-policy.osl" testing.assert(roturReportsMinor({valid: true, minor: true}), "rotur can report a minor") diff --git a/src/auth/routes.osl b/src/auth/routes.osl new file mode 100644 index 0000000..42267b8 --- /dev/null +++ b/src/auth/routes.osl @@ -0,0 +1,28 @@ +def registerAuthRoutes(*serve.Router api) ( + api.GET("/health", handleHealth) + api.GET("/health/dependencies", handleDependencyHealth) + + api.POST("/auth", handleRoturAuth) + api.POST("/auth/editor-session", serve.limitRequests(600, {window: 3600000}), requireAuth, handleCreateEditorSession) + api.GET("/me", handleGetMe) + api.POST("/logout", handleLogout) + + api.GET("/me/settings", requireAuth, handleGetMySettings) + api.PUT("/me/settings", requireAuth, handlePutMySettings) + + api.GET("/me/export", requireAuth, handleExportMyData) + api.DELETE("/me/data", requireAuth, handleDeleteMyData) + + api.GET("/me/safety", requireAuth, handleGetMySafety) + api.POST("/me/blocks/:name", requireAuth, handleBlockUser) + api.DELETE("/me/blocks/:name", requireAuth, handleUnblockUser) + api.POST("/me/mutes/:name", requireAuth, handleMuteUser) + api.DELETE("/me/mutes/:name", requireAuth, handleUnmuteUser) + + api.GET("/agreement", handleGetAgreement) + api.POST("/agreement/accept", requireAuth, handleAcceptAgreement) + + api.GET("/admin/agreement", requireAdmin, handleAdminGetAgreement) + api.POST("/admin/agreement", requireAdmin, handleAdminUpdateAgreement) + api.POST("/admin/reconcile-deletions", requireAdmin, handleReconcileDeletions) +) diff --git a/src/auth/user-identity.osl b/src/auth/user-identity.osl index b2021a7..9af7891 100644 --- a/src/auth/user-identity.osl +++ b/src/auth/user-identity.osl @@ -1,3 +1,6 @@ +auto userIdsCache = cache.new({capacity: 10000, ttl: 300000}) +auto identityRefreshCache = cache.new({capacity: 10000, ttl: 300000}) + def loadUserIds() object ( auto res = readFile(userIdsFile) if !res.ok ( diff --git a/src/classroom/classroom-switch.test.osl b/src/classroom/classroom-switch.test.osl index b2a5ddf..2bbae3e 100644 --- a/src/classroom/classroom-switch.test.osl +++ b/src/classroom/classroom-switch.test.osl @@ -8,7 +8,7 @@ import "std:serve" import "std:sync" import "std:thread" import "std:url" -import "./request-body.osl" +import "../http/request-body.osl" import "./classroom-policy.osl" import "./classroom-switch-policy.osl" diff --git a/src/classroom/classrooms.osl b/src/classroom/classrooms.osl index 60a399b..1433ec5 100644 --- a/src/classroom/classrooms.osl +++ b/src/classroom/classrooms.osl @@ -1,3 +1,5 @@ +auto classroomPoolCache = cache.new({capacity: 10000, ttl: 60000}) + def randomFromAlphabet(string alphabet, number length) string ( string out = "" for _ length ( diff --git a/src/classroom/routes.osl b/src/classroom/routes.osl new file mode 100644 index 0000000..f1fcee7 --- /dev/null +++ b/src/classroom/routes.osl @@ -0,0 +1,57 @@ +def registerClassroomRoutes(*serve.Router api) ( + api.GET("/classroom/join/:code", serve.limitRequests(30, {window: 60000}), handleClassroomJoinInfo) + api.POST("/classroom/login", serve.limitRequests(20, {window: 60000}), handleClassroomLogin) + api.GET("/classroom/available", handleClassroomAvailable) + api.GET("/classroom", requireAuth, handleGetClassroom) + api.POST("/classroom/terms/accept", requireClassroomTeacher, serve.limitRequests(20, {window: 3600000}), handleAcceptClassroomTerms) + api.GET("/classroom/school", requireClassroomTeacher, handleGetSchool) + api.GET("/classroom/school/audit", requireClassroomTeacher, handleGetSchoolAudit) + api.GET("/classroom/school/export", requireClassroomTeacher, serve.limitRequests(10, {window: 3600000}), handleExportSchool) + api.POST("/classroom/school/teachers", requireClassroomTeacher, requireClassroomTerms, handleAddSchoolTeacher) + api.DELETE("/classroom/school/teachers/:teacher", requireClassroomTeacher, handleRemoveSchoolTeacher) + api.PUT("/classroom/school/teachers/:teacher/admin", requireClassroomTeacher, handleSetSchoolAdmin) + api.POST("/classroom/billing/checkout", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(20, {window: 3600000}), handleClassroomCheckout) + api.POST("/classroom/billing/portal", requireClassroomTeacher, serve.limitRequests(30, {window: 3600000}), handleClassroomBillingPortal) + api.POST("/classroom/billing/webhook", handleStripeWebhook) + api.POST("/classroom/trial", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(10, {window: 3600000}), handleStartClassroomTrial) + api.POST("/classroom/classes", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(20, {window: 3600000}), handleCreateClass) + api.GET("/classroom/classes/:id", requireClassroomTeacher, handleGetClass) + api.PUT("/classroom/classes/:id", requireClassroomTeacher, handleUpdateClass) + api.DELETE("/classroom/classes/:id", requireClassroomTeacher, handleDeleteClass) + api.POST("/classroom/classes/:id/code", requireClassroomTeacher, handleResetClassCode) + api.POST("/classroom/classes/:id/teachers", requireClassroomTeacher, requireClassroomTerms, handleAddCoTeacher) + api.DELETE("/classroom/classes/:id/teachers/:teacher", requireClassroomTeacher, handleRemoveCoTeacher) + api.POST("/classroom/classes/:id/transfer", requireClassroomTeacher, handleTransferClass) + api.GET("/classroom/classes/:id/audit", requireClassroomTeacher, handleGetClassAudit) + api.GET("/classroom/classes/:id/export", requireClassroomTeacher, serve.limitRequests(30, {window: 3600000}), handleExportClass) + api.GET("/classroom/classes/:id/groups", requireClassroomTeacher, handleListClassGroups) + api.POST("/classroom/classes/:id/present", requireClassroomTeacher, requireClassroomTerms, handleStartPresentation) + api.DELETE("/classroom/classes/:id/present", requireClassroomTeacher, handleStopPresentation) + api.POST("/classroom/classes/:id/groups", requireClassroomTeacher, requireClassroomTerms, handleCreateClassGroup) + api.DELETE("/classroom/classes/:id/groups/:gid", requireClassroomTeacher, handleDeleteClassGroup) + api.POST("/classroom/classes/:id/students", requireClassroomTeacher, requireClassroomTerms, serve.limitRequests(30, {window: 3600000}), handleCreateStudents) + api.GET("/classroom/classes/:id/students/:sid", requireClassroomTeacher, handleGetClassStudent) + api.GET("/classroom/classes/:id/students/:sid/export", requireClassroomTeacher, serve.limitRequests(60, {window: 3600000}), handleExportClassStudent) + api.PUT("/classroom/classes/:id/students/:sid", requireClassroomTeacher, handleUpdateClassStudent) + api.DELETE("/classroom/classes/:id/students/:sid", requireClassroomTeacher, handleDeleteClassStudent) + api.POST("/classroom/classes/:id/students/:sid/reset", requireClassroomTeacher, handleResetStudentLogin) + api.POST("/classroom/classes/:id/students/:sid/move", requireClassroomTeacher, handleMoveClassStudent) + api.POST("/classroom/classes/:id/students/:sid/release", requireClassroomTeacher, requireClassroomTerms, handleReleaseStudent) + api.DELETE("/classroom/classes/:id/students/:sid/release", requireClassroomTeacher, handleCancelStudentRelease) + api.GET("/classroom/claim/:code", serve.limitRequests(30, {window: 60000}), handleGetClaim) + api.POST("/classroom/claim/:code", requireAuth, serve.limitRequests(10, {window: 60000}), handleRedeemClaim) + api.POST("/classroom/classes/:id/assignments", requireClassroomTeacher, requireClassroomTerms, handleCreateAssignment) + api.GET("/classroom/classes/:id/assignments/:aid", requireClassroomTeacher, handleGetAssignment) + api.PUT("/classroom/classes/:id/assignments/:aid", requireClassroomTeacher, handleUpdateAssignment) + api.DELETE("/classroom/classes/:id/assignments/:aid", requireClassroomTeacher, handleDeleteAssignment) + api.PUT("/classroom/classes/:id/assignments/:aid/submissions/:sid", requireClassroomTeacher, handleReviewSubmission) + api.POST("/classroom/assignments/:aid/start", requireAuth, handleStartAssignment) + api.POST("/classroom/assignments/:aid/turn-in", requireAuth, handleTurnInAssignment) + api.POST("/classroom/assignments/:aid/unsubmit", requireAuth, handleUnsubmitAssignment) + + api.GET("/admin/classroom/plans", requireAdmin, handleAdminGetClassroomPlan) + api.POST("/admin/classroom/plans", requireAdmin, handleAdminSetClassroomPlan) + api.GET("/admin/classroom/schools", requireAdmin, handleAdminListSchools) + api.POST("/admin/classroom/schools", requireAdmin, handleAdminCreateSchool) + api.POST("/admin/classroom/purge", requireAdmin, handleAdminPurgeClassroom) +) diff --git a/src/community/development.osl b/src/community/development.osl index a8dcca9..e8477be 100644 --- a/src/community/development.osl +++ b/src/community/development.osl @@ -1,3 +1,5 @@ +auto developmentCache = cache.new({capacity: 4, ttl: 300000}) + // MistWarp's own source lives in public GitHub repositories, so the work in // flight there is invisible from inside the site. This pulls the org's open // and recently merged pull requests into the community API and links them to diff --git a/src/community/groups.osl b/src/community/groups.osl index 8d39de3..709dada 100644 --- a/src/community/groups.osl +++ b/src/community/groups.osl @@ -1,3 +1,5 @@ +auto groupSnapshotCache = cache.new({capacity: 10000, ttl: 60000}) + def normalizeGroupTag(string value) string ( string tag = value.toLower().strip() if tag.len < 1 or tag.len > 10 or !regex.match("^[a-z0-9]+$", tag) ( diff --git a/src/community/like-milestones.test.osl b/src/community/like-milestones.test.osl index 68d4a79..0bebd96 100644 --- a/src/community/like-milestones.test.osl +++ b/src/community/like-milestones.test.osl @@ -16,7 +16,6 @@ string path = "/tmp/mistwarp-milestones-" ++ timestamp.toStr() ++ ".db" auto milestoneDb = db.open(path) *db.Collection likeMilestoneDocs = milestoneDb.collection("milestones") string roturBase = "http://127.0.0.1:18342" -auto remoteMilestoneChecks = cache.new({capacity: 100, ttl: 10000}) array sent = [] def isSafePathPart(string value) boolean ( diff --git a/src/community/remote-milestones.osl b/src/community/remote-milestones.osl index e91c04f..5ca3857 100644 --- a/src/community/remote-milestones.osl +++ b/src/community/remote-milestones.osl @@ -1,3 +1,5 @@ +auto remoteMilestoneChecks = cache.new({capacity: 10000, ttl: 10000}) + // Use existing public Rotur APIs. Counts, recipients and links never come // from the caller. This records milestones observed through MistWarp. def remoteMilestoneData(string path) object ( diff --git a/src/community/routes.osl b/src/community/routes.osl new file mode 100644 index 0000000..3166d56 --- /dev/null +++ b/src/community/routes.osl @@ -0,0 +1,138 @@ +def registerCommunityUploadRoutes(*serve.Router uploads) ( + uploads.POST("/spaces/:id/thumbnail", serve.maxBodySize(maxThumbnailRequestBytes), requireAuth, handleSetSpaceThumbnail) +) + +def registerCommunityRoutes(*serve.Router api) ( + api.POST("/milestones/followers/:name", requireAuth, handleCheckFollowerMilestones) + api.POST("/milestones/posts/:id", requireAuth, handleCheckPostMilestones) + api.POST("/milestones/themes/:id", requireAuth, handleCheckThemeMilestones) + api.GET("/notifications", requireAuth, handleGetNotifications) + api.POST("/notifications/read", requireAuth, handleReadNotifications) + + api.GET("/me/library", requireAuth, handleGetMyLibrary) + + api.PUT("/me/library/:project/visibility", requireAuth, handleSetLibraryProjectVisibility) + + api.GET("/me/purchases", requireAuth, handleGetMyPurchases) + + api.GET("/groups/:tag", handleGetMistWarpGroup) + + api.GET("/development/pulls", handleDevelopmentPulls) + api.GET("/roadmap", handleListIdeas) + api.POST("/roadmap", requireAuth, requireGoodStanding, handleCreateIdea) + api.POST("/roadmap/:id/vote", requireAuth, handleVoteIdea) + api.PUT("/roadmap/:id", requireAdmin, handleUpdateIdea) + api.GET("/roadmap/:id/comments", handleGetIdeaComments) + api.POST("/roadmap/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateIdeaComment) + api.DELETE("/roadmap/:id/comments/:cid", requireAuth, handleDeleteIdeaComment) + api.PUT("/roadmap/:id/comments/:cid", requireAuth, handleEditIdeaComment) + api.POST("/roadmap/:id/comments/:cid/react", requireAuth, handleReactIdeaComment) + api.GET("/spaces", handleListSpaces) + api.POST("/spaces", requireAuth, requireGoodStanding, handleCreateSpace) + api.GET("/spaces/:id", handleGetSpace) + api.GET("/spaces/:id/manage", requireAuth, handleGetSpaceManagement) + api.PUT("/spaces/:id", requireAuth, handleUpdateSpace) + api.DELETE("/spaces/:id", requireAuth, handleDeleteSpace) + api.POST("/spaces/:id/projects/:project", requireAuth, handleAddSpaceProject) + api.DELETE("/spaces/:id/projects/:project", requireAuth, handleRemoveSpaceProject) + api.POST("/spaces/:id/follow", requireAuth, handleFollowSpace) + api.DELETE("/spaces/:id/follow", requireAuth, handleUnfollowSpace) + api.POST("/spaces/:id/curators", requireAuth, handleInviteSpaceCurator) + api.DELETE("/spaces/:id/curators/:username", requireAuth, handleRemoveSpaceCurator) + api.POST("/spaces/:id/invitations/respond", requireAuth, handleRespondSpaceCuratorInvite) + api.DELETE("/spaces/:id/invitations/:username", requireAuth, handleCancelSpaceCuratorInvite) + api.POST("/spaces/:id/judges", requireAuth, handleInviteChallengeJudge) + api.DELETE("/spaces/:id/judges/:username", requireAuth, handleRemoveChallengeJudge) + api.POST("/spaces/:id/judge-invitations/respond", requireAuth, handleRespondChallengeJudgeInvite) + api.POST("/spaces/:id/join", requireAuth, requireGoodStanding, handleJoinChallenge) + api.DELETE("/spaces/:id/join", requireAuth, handleLeaveChallenge) + api.PUT("/spaces/:id/entries/:project/score", requireAuth, handleScoreChallengeEntry) + api.PUT("/spaces/:id/entries/:project/vote", requireAuth, handleVoteChallengeEntry) + api.POST("/spaces/:id/results", requireAuth, handlePublishChallengeResults) + api.POST("/spaces/:id/react", requireAuth, handleReactSpace) + api.GET("/spaces/:id/comments", handleGetSpaceComments) + api.POST("/spaces/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateSpaceComment) + api.DELETE("/spaces/:id/comments/:cid", requireAuth, handleDeleteSpaceComment) + api.PUT("/spaces/:id/comments/:cid", requireAuth, handleEditSpaceComment) + api.POST("/spaces/:id/comments/:cid/react", requireAuth, handleReactSpaceComment) + api.POST("/spaces/:id/comments/:cid/pin", requireAuth, handlePinSpaceComment) + api.GET("/me/spaces", requireAuth, handleMySpaces) + + api.GET("/projects/:id/card.svg", handleProjectCard) + api.GET("/projects/:id/card.png", handleProjectSharePng) + + api.POST("/projects/:id/save", requireAuth, handleSaveProject) + api.DELETE("/projects/:id/save", requireAuth, handleUnsaveProject) + + api.POST("/projects/:id/preview", requireAuth, serve.limitRequests(30, {window: 3600000}), handleCreatePreview) + api.GET("/projects/:id/releases", handleListReleases) + api.GET("/projects/:id/releases/:release/project.json", handleGetReleaseProjectJson) + api.POST("/projects/:id/releases", requireAuth, serve.limitRequests(30, {window: 3600000}), handleCreateRelease) + api.DELETE("/projects/:id/releases/:release", requireAuth, handleDeleteRelease) + api.POST("/projects/:id/contribute", requireAuth, handleCreateContribution) + api.POST("/projects/:id/diagnostics", serve.limitRequests(120, {window: 60000}), handleRecordDiagnostic) + api.GET("/projects/:id/diagnostics", requireAuth, handleGetDiagnostics) + api.POST("/projects/:id/feedback", requireAuth, requireGoodStanding, serve.limitRequests(20, {window: 3600000}), handleCreateProjectFeedback) + api.GET("/projects/:id/feedback", requireAuth, handleGetProjectFeedback) + api.PUT("/projects/:id/feedback/:feedback", requireAuth, handleUpdateProjectFeedback) + api.GET("/projects/:id/reviews", handleListProjectReviews) + api.PUT("/projects/:id/reviews/me", requireAuth, requireGoodStanding, handleSaveProjectReview) + api.DELETE("/projects/:id/reviews/me", requireAuth, handleDeleteProjectReview) + + api.GET("/projects/:id/comments", handleGetProjectComments) + api.POST("/projects/:id/comments/donation/intent", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleProjectCommentDonationIntent) + api.POST("/projects/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateProjectComment) + api.DELETE("/projects/:id/comments/:cid", requireAuth, handleDeleteProjectComment) + api.PUT("/projects/:id/comments/:cid", requireAuth, handleEditProjectComment) + api.POST("/projects/:id/comments/:cid/react", requireAuth, handleReactProjectComment) + api.POST("/projects/:id/comments/:cid/pin", requireAuth, handlePinProjectComment) + + api.GET("/bounties/:id/activity", handleGetBountyActivity) + api.POST("/bounties/:id/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateBountyComment) + api.PUT("/bounties/:id/comments/:cid", requireAuth, handleEditBountyComment) + api.DELETE("/bounties/:id/comments/:cid", requireAuth, handleDeleteBountyComment) + api.POST("/bounties/:id/workers/me", requireAuth, requireGoodStanding, handleJoinBounty) + api.DELETE("/bounties/:id/workers/me", requireAuth, handleLeaveBounty) + + api.GET("/search/users", handleSearchUsers) + api.GET("/users/:name", handleGetUser) + api.GET("/users/:name/library", handleGetUserPlaytimeLibrary) + api.GET("/users/:name/reviews", handleListUserReviews) + api.PUT("/me/profile", requireAuth, handleUpdateProfile) + api.POST("/users/:name/follow", requireAuth, handleFollowUser) + api.DELETE("/users/:name/follow", requireAuth, handleUnfollowUser) + api.GET("/users/:name/projects", handleGetUserProjects) + api.GET("/activity", handleGetActivity) + api.GET("/users/:name/followers", handleGetUserFollowers) + api.GET("/users/:name/following", handleGetUserFollowing) + api.GET("/users/:name/comments", handleGetProfileComments) + api.POST("/users/:name/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreateProfileComment) + api.DELETE("/users/:name/comments/:cid", requireAuth, handleDeleteProfileComment) + api.PUT("/users/:name/comments/:cid", requireAuth, handleEditProfileComment) + api.POST("/users/:name/comments/:cid/react", requireAuth, handleReactProfileComment) + api.POST("/users/:name/comments/:cid/pin", requireAuth, handlePinProfileComment) + + api.GET("/news", handleGetNews) + api.GET("/news/:id", handleGetNewsItem) + api.POST("/news/:id/view", serve.limitRequests(120, {window: 3600000}), handleViewNews) + api.POST("/news", requireAdmin, handleCreateNews) + api.PUT("/news/:id", requireAdmin, handleUpdateNews) + api.DELETE("/news/:id", requireAdmin, handleDeleteNews) + api.POST("/news/:id/react", requireAuth, handleReactNews) + api.POST("/news/:id/poll", requireAuth, handleVoteNewsPoll) + + api.POST("/report", requireAuth, serve.limitRequests(10, {window: 3600000}), handleCreateReport) + api.POST("/reports", requireAuth, serve.limitRequests(10, {window: 3600000}), handleCreateReport) + api.POST("/support", serve.limitRequests(5, {window: 3600000}), handleCreateSupportRequest) + + api.GET("/admin/reports", requireAdmin, handleGetReports) + api.GET("/admin/reports/evidence/:id", requireAdmin, handleGetReportEvidence) + api.POST("/admin/reports/resolve", requireAdmin, handleResolveReport) + + api.POST("/admin/reports/escalate", requireAdmin, handleEscalateReport) + + api.POST("/admin/standing", requireAdmin, handleSetStanding) + + api.POST("/admin/user/message", requireAdmin, handleMessageUser) + api.POST("/admin/user/profile", requireAdmin, handleAdminUpdateProfile) +) diff --git a/src/config/config.osl b/src/config/config.osl new file mode 100644 index 0000000..4da4553 --- /dev/null +++ b/src/config/config.osl @@ -0,0 +1,160 @@ +def dotenvValue(string name) string ( + if !fs.exists(".env") ( + return "" + ) + string contents = fs.readFile(".env") catch (err) ( + log "WARNING: could not read .env: " ++ err.toStr() + return "" + ) + array lines = contents.split("\n") + for i lines.len ( + string line = lines[i].toStr().strip() + if line.startsWith("export ") ( + line = line.replace("export ", "").strip() + ) + if line != "" and !line.startsWith("#") and line.contains("=") ( + array parts = line.split("=") + string key = parts[1].toStr().strip() + if key == name ( + string value = parts[2].toStr().strip() + for p parts.len ( + if p > 2 ( + value ++= "=" ++ parts[p].toStr() + ) + ) + return value.replace("\"", "").replace("'", "") + ) + ) + ) + return "" +) + +def envValue(string name) string ( + string value = env.get(name, "") catch (err) ( + log "WARNING: could not read " ++ name ++ " from the environment: " ++ err.toStr() + return "" + ) + return value +) + +// Configuration is looked up in this order: +// 1. MISTWARP_ in the real environment (an explicit, app-specific override) +// 2. this app's .env +// 3. in the real environment (for deployments that don't ship a .env) +// +// Inherited variables come last on purpose. The API is often started from a +// shared shell that exports another project's variables (for example Rotur's +// R2_ACCESS_KEY_ID and R2_PUBLIC_BASE), and those must not silently replace +// this service's own configuration. +def configValue(string name, string defaultValue) string ( + string value = envValue("MISTWARP_" ++ name) + if value != "" ( + return value + ) + value = dotenvValue(name) + if value != "" ( + return value + ) + value = envValue(name) + if value != "" ( + return value + ) + return defaultValue +) + +string projectsDir = "data/projects/" +string indexFile = "data/projects-index.json" +string assetsIndexFile = "data/assets-index.json" +string usersDir = "data/users/" +string sessionsDir = "data/sessions/" +string commentsDir = "data/comments/" +string reportsDir = "data/reports/" +string reportsFile = reportsDir ++ "reports.json" +string reportEvidenceDir = "data/report-evidence/" +string newsFile = "data/news.json" +string notificationsDir = "data/notifications/" +string activityDir = "data/activity/" +string userSettingsDir = "data/usersettings/" +string tmpDir = "data/tmp/" +string stagingDir = "data/staging/" +string thumbnailsDir = "data/thumbnails/" +string cardsDir = "data/cards/" +string thumbnailSecretFile = "data/thumbnail-secret" +string quotaDir = "data/quota/" +string userIdsFile = "data/userids.json" +string adminsFile = "data/admins.json" +string bansFile = "data/bans.json" +string shadowBansFile = "data/shadowbans.json" +string agreementAcceptDir = "data/agreement-acceptances/" +string extensionsDir = "data/extensions/" +string releasesDir = "data/releases/" +string extensionsFile = "data/extensions.json" +string safetyDir = "data/safety/" +string trashDir = "data/trash/" +string gameSavesDir = "data/game-saves/" +string gameSaveUsageDir = "data/game-save-usage/" +string legacyAccountGameDataDir = "data/account-game-data/" +string gameEntitlementsDir = "data/game-entitlements/" +string gameInventoryDir = "data/game-inventory/" +string backpackDir = "data/backpack/" + +number maxWorkspaceBytes = 536870912 +number maxWorkspaceFiles = 1000000 +number maxUploadChunkBytes = 8388608 + +number maxProjectJsonBytes = 268435456 +number maxStoredProjectJsonBytes = 20971520 +number maxThumbBytes = 1048576 +number maxAssetCount = 2000 +number quotaWindowMs = 604800000 +number uploadDebounceMs = 604800000 +number maxProjectExtensions = 100 +number maxExtensionSourceBytes = 2097152 +number maxProjectExtensionBytes = 10485760 + +string appURL = configValue("APP_URL", "https://api.mistwarp.org") +string port = configValue("PORT", "5627") +string roturAppKey = configValue("ROTUR_APP_KEY", "mistwarp") +string r2Endpoint = configValue("R2_ENDPOINT", "") +string r2Bucket = configValue("R2_BUCKET", "mistwarp") +string r2AccessKeyId = configValue("R2_ACCESS_KEY_ID", "") +string r2SecretAccessKey = configValue("R2_SECRET_ACCESS_KEY", "") +string r2PublicBase = configValue("R2_PUBLIC_BASE", "") +string giteaURL = configValue("GITEA_URL", "https://git.rotur.dev") +string giteaAdminToken = configValue("GITEA_ADMIN_TOKEN", "") +string githubOrg = configValue("GITHUB_ORG", "MistWarp") +string githubToken = configValue("GITHUB_TOKEN", "") +string editorOrigin = configValue("EDITOR_ORIGIN", "https://mistwarp.org") +array adminUsers = configValue("ADMIN_USERS", "mist").toLower().split(",") +string mistwarpRoturToken = configValue("MISTWARP_ROTUR_TOKEN", "") +string mistwarpRoturUser = configValue("MISTWARP_ROTUR_USER", "") +string roturBase = configValue("ROTUR_BASE", "https://api.rotur.dev") +string commerceServiceKey = configValue("COMMERCE_SERVICE_KEY", "") +string stripeSecretKey = configValue("STRIPE_SECRET_KEY", "") +string stripeWebhookSecret = configValue("STRIPE_WEBHOOK_SECRET", "") +string stripeClassroomPrice = configValue("STRIPE_CLASSROOM_PRICE", "") +string stripeSeatPackPrice = configValue("STRIPE_SEAT_PACK_PRICE", "") +string stripeClassroomPriceYearly = configValue("STRIPE_CLASSROOM_PRICE_YEARLY", "") +string stripeSeatPackPriceYearly = configValue("STRIPE_SEAT_PACK_PRICE_YEARLY", "") +string realtimeURL = configValue("REALTIME_URL", "wss://api.mistwarp.org/v1/connect") +number historyMigrationWorkers = configValue("HISTORY_MIGRATION_WORKERS", "4").toNum().toInt() +if historyMigrationWorkers < 1 ( + historyMigrationWorkers = 1 +) +if historyMigrationWorkers > 8 ( + historyMigrationWorkers = 8 +) +string purchaseKeysFile = "data/purchase-keys.json" + +boolean r2Local = r2Endpoint == "" or r2AccessKeyId == "" +string localBlobsDir = "data/blobs/" + +*s3.Client r2Client = s3.new({ + "endpoint": if r2Local then "http://127.0.0.1:9" else r2Endpoint, + "bucket": r2Bucket, + "access_key_id": r2AccessKeyId, + "secret_access_key": r2SecretAccessKey, + "region": "auto" +}) + +void fs.mkdir("data/", {recursive: true}) diff --git a/src/games/game-data.osl b/src/games/game-data.osl index f089e69..a9453fe 100644 --- a/src/games/game-data.osl +++ b/src/games/game-data.osl @@ -1,3 +1,5 @@ +auto gameDataCapabilityCache = cache.new({capacity: 100000, ttl: 300000}) + number gameSaveMaxBytes = 65536 number gameDataCapabilityTtlMs = 300000 number multiplayerTicketTtlMs = 60000 diff --git a/src/games/game-inventory.test.osl b/src/games/game-inventory.test.osl index 381a118..08ac7a7 100644 --- a/src/games/game-inventory.test.osl +++ b/src/games/game-inventory.test.osl @@ -2,7 +2,7 @@ import "std:testing" import "std:sync" import "std:serve" import "std:regex" -import "./request-body.osl" +import "../http/request-body.osl" import "./game-inventory.osl" def isSafePathPart(string value) boolean ( diff --git a/src/games/routes.osl b/src/games/routes.osl new file mode 100644 index 0000000..12fe76c --- /dev/null +++ b/src/games/routes.osl @@ -0,0 +1,21 @@ +def registerGameRoutes(*serve.Router api) ( + api.GET("/me/game-saves", requireAuth, handleListMyGameSaves) + api.GET("/me/game-inventory", requireAuth, handleGetMyGameInventory) + + api.DELETE("/me/game-saves/:project", requireAuth, handleDeleteMyGameSave) + + api.POST("/projects/:id/data-capability", requireAuth, handleCreateGameDataCapability) + api.POST("/projects/:id/multiplayer-ticket", requireAuth, handleCreateMultiplayerTicket) + api.GET("/projects/:id/products", handleListGameProducts) + api.GET("/projects/:id/products/:product/owns", requireAuth, handleOwnsGameProduct) + api.POST("/projects/:id/products/:product/purchase/intent", requireAuth, handleGameProductIntent) + api.POST("/projects/:id/products/:product/purchase/confirm", requireAuth, handleGameProductConfirm) + api.POST("/projects/:id/products/:product/grant", requireAuth, handleGrantGameProduct) + api.POST("/projects/:id/products/:product/revoke", requireAuth, handleRevokeGameProduct) + api.POST("/projects/:id/products/:product/refund/intent", requireAuth, handleGameProductRefundIntent) + api.GET("/projects/:id/game-inventory-config", requireAuth, handleGetGameInventoryConfig) + api.GET("/projects/:id/me/inventory", requireAuth, handleGetProjectInventory) + api.POST("/projects/:id/me/inventory/grant", requireAuth, handleGrantProjectItem) + api.GET("/projects/:id/me/save", requireAuth, handleGetMyProjectSave) + api.PUT("/projects/:id/me/save", requireAuth, handlePutMyProjectSave) +) diff --git a/src/git/git-inspection.osl b/src/git/git-inspection.osl index a27d4d4..7db19f6 100644 --- a/src/git/git-inspection.osl +++ b/src/git/git-inspection.osl @@ -1,3 +1,5 @@ +auto gitInspectionPruneCache = cache.new({capacity: 4, ttl: 300000}) + def projectWorkspaceFingerprint(object project) string ( array keys = projectWorkspaceKeys(project) string identity = "" diff --git a/src/git/routes.osl b/src/git/routes.osl new file mode 100644 index 0000000..cd76787 --- /dev/null +++ b/src/git/routes.osl @@ -0,0 +1,22 @@ +def registerGitRoutes(*serve.Router api) ( + api.GET("/projects/:id/commits", handleGetCommits) + api.GET("/projects/:id/branches", handleListProjectBranches) + api.POST("/projects/:id/branches", requireAuth, handleCreateProjectBranch) + api.PATCH("/projects/:id/branches", requireAuth, handleRenameProjectBranch) + api.DELETE("/projects/:id/branches", requireAuth, handleDeleteProjectBranch) + api.POST("/projects/:id/branches/merge", requireAuth, handlePrepareBranchMerge) + api.GET("/projects/:id/commits/:sha", handleGetCommitInspection) + + api.GET("/projects/:id/commits/:sha/tree", handleGetCommitTree) + api.GET("/projects/:id/commits/:sha/file", handleGetCommitFile) + api.GET("/projects/:id/pulls", handleListPulls) + api.POST("/projects/:id/pulls", requireAuth, handleCreatePull) + api.GET("/projects/:id/pulls/:index", handleGetPull) + api.GET("/projects/:id/pulls/:index/timeline", handleGetPullTimeline) + api.POST("/projects/:id/pulls/:index/comments", requireAuth, requireGoodStanding, serve.limitRequests(30, {window: 60000}), handleCreatePullComment) + api.PUT("/projects/:id/pulls/:index/comments/:cid", requireAuth, handleEditPullComment) + api.DELETE("/projects/:id/pulls/:index/comments/:cid", requireAuth, handleDeletePullComment) + api.GET("/projects/:id/pulls/:index/diff", requireAuth, handleGetPullDiff) + api.POST("/projects/:id/pulls/:index/merge", requireAuth, handleMergePull) + api.POST("/projects/:id/pulls/:index/close", requireAuth, handleClosePull) +) diff --git a/src/history/routes.osl b/src/history/routes.osl new file mode 100644 index 0000000..848bfd3 --- /dev/null +++ b/src/history/routes.osl @@ -0,0 +1,5 @@ +def registerHistoryRoutes(*serve.Router api) ( + api.PATCH("/projects/:id/commits/:sha", requireAuth, handlePatchCommit) + api.GET("/projects/:id/commits/:sha/co-authors", handleGetCommitCollaborators) + api.GET("/projects/:id/commits/:sha/collaborators", handleGetCommitCollaborators) +) diff --git a/src/projects/projects.osl b/src/projects/projects.osl index 72fb33a..4673e68 100644 --- a/src/projects/projects.osl +++ b/src/projects/projects.osl @@ -1,3 +1,7 @@ +auto viewDedup = cache.new({capacity: 100000, ttl: 21600000}) +auto viewIndexFlush = cache.new({capacity: 100000, ttl: 600000}) +auto sharedProjectsCache = cache.new({capacity: 2, ttl: 60000}) + def removeString(array items, string value) array ( array next = [] for i items.len ( diff --git a/src/projects/routes.osl b/src/projects/routes.osl new file mode 100644 index 0000000..eccaa12 --- /dev/null +++ b/src/projects/routes.osl @@ -0,0 +1,60 @@ +def registerProjectUploadRoutes(*serve.Router uploads) ( + uploads.POST("/projects/:id/upload", serve.maxBodySize(maxProjectUploadRequestBytes), requireAuth, requireCurrentAgreement, handleUploadProject) + uploads.POST("/projects/:id/upload-parts/:token/chunk", serve.maxBodySize(maxUploadChunkRequestBytes), requireAuth, requireCurrentAgreement, handleUploadProjectPartChunk) + uploads.POST("/projects/:id/history/bootstrap", serve.maxBodySize(maxWorkspaceBootstrapRequestBytes), requireAuth, requireCurrentAgreement, handleBootstrapProjectHistory) + uploads.POST("/projects/:id/thumbnail", serve.maxBodySize(maxThumbnailRequestBytes), requireAuth, handleSetThumbnail) + + uploads.POST("/admin/projects/:id/extensions/index", serve.maxBodySize(maxExtensionIndexRequestBytes), requireAdmin, handleAdminIndexProjectExtensions) +) + +def registerProjectRoutes(*serve.Router api) ( + api.GET("/me/quota", requireAuth, handleGetMyQuota) + api.GET("/me/stats", requireAuth, handleGetMyStats) + + api.GET("/me/shared-projects", requireAuth, handleGetSharedProjects) + + api.GET("/me/trash", requireAuth, handleGetMyTrash) + + api.GET("/explore", handleExplore) + api.GET("/vanity/:slug", handleResolveVanityProject) + + api.GET("/leaderboard", handleLeaderboard) + + api.POST("/projects", requireAuth, serve.limitRequests(30, {window: 3600000}), handleCreateProject) + api.POST("/projects/:id/live", requireAuth, serve.limitRequests(120, {window: 60000}), handleLiveCollaboration) + api.POST("/projects/:id/assets/check", requireAuth, handleCheckProjectAssets) + api.POST("/projects/:id/upload-parts", requireAuth, requireCurrentAgreement, handleCreateProjectUploadPart) + api.POST("/projects/:id/upload-parts/:token/complete", requireAuth, requireCurrentAgreement, handleCompleteProjectUploadPart) + api.GET("/projects/random", handleRandomProject) + api.GET("/projects/featured", handleFeaturedProject) + api.GET("/projects/:id/related", handleRelatedProjects) + api.GET("/sitemap.xml", handleSitemap) + api.GET("/projects/:id/project.json", handleGetProjectJson) + api.GET("/projects/:id/trusted-extensions", handleGetProjectTrustedExtensions) + + api.GET("/projects/:id/extensions/:hash/source", handleGetProjectExtensionSource) + api.GET("/projects/:id/editor", handleGetEditorProject) + api.GET("/projects/:id", handleGetProject) + + api.PUT("/projects/:id", requireAuth, handleUpdateProject) + api.DELETE("/projects/:id", requireAuth, handleDeleteProject) + api.POST("/projects/:id/restore", requireAuth, handleRestoreProject) + api.DELETE("/trash/:id", requireAuth, handlePurgeTrashedProject) + api.POST("/projects/:id/publish", requireAuth, requireGoodStanding, handlePublishProject) + api.POST("/projects/:id/unpublish", requireAuth, handleUnpublishProject) + api.POST("/projects/:id/visibility", requireAuth, requireGoodStanding, handleSetVisibility) + api.POST("/projects/:id/purchase/intent", handleProjectSalesEnded) + api.POST("/projects/:id/purchase/confirm", handleProjectSalesEnded) + api.POST("/projects/:id/view", handleViewProject) + + api.POST("/projects/:id/react", requireAuth, handleReactProject) + api.GET("/projects/:id/remixes", handleGetRemixes) + api.GET("/projects/:id/remixtree", handleGetRemixTree) + api.POST("/projects/:id/remix", requireAuth, serve.limitRequests(30, {window: 3600000}), handleRemixProject) + + api.GET("/admin/extensions", requireAdmin, handleAdminGetExtensions) + + api.GET("/admin/extensions/:hash/source", requireAdmin, handleAdminGetExtensionSource) + api.POST("/admin/extensions/policy", requireAdmin, handleAdminSetExtensionPolicy) + api.POST("/admin/extensions/url-policy", requireAdmin, handleAdminSetExtensionUrlPolicy) +) diff --git a/src/realtime/game-realtime.osl b/src/realtime/game-realtime.osl index 3bd3f53..01f07bb 100644 --- a/src/realtime/game-realtime.osl +++ b/src/realtime/game-realtime.osl @@ -1,3 +1,6 @@ +auto multiplayerTicketCache = cache.new({capacity: 100000, ttl: 120000}) +auto realtimeHandshakeCache = cache.new({capacity: 100000, ttl: 30000}) + def realtimeRoomKey(object identity) string ( return identity.projectId.toStr() ++ ":" ++ identity.projectVersion.toStr() ++ ":" ++ identity.context.toStr() ++ ":" ++ identity.room.toStr() ) diff --git a/src/rotur/perks.osl b/src/rotur/perks.osl index f8aca0a..486fcc7 100644 --- a/src/rotur/perks.osl +++ b/src/rotur/perks.osl @@ -1,3 +1,7 @@ +auto paidPerksCache = cache.new({capacity: 10000, ttl: 300000}) +auto lastKnownTiers = cache.new({capacity: 10000, ttl: 2592000000}) +auto roturTierUnreachable = cache.new({capacity: 10000, ttl: 60000}) + def roturTierForUser(string userId) string ( return roturTierLookup(userId).tier.toStr() ) diff --git a/src/rotur/rotur-payments.osl b/src/rotur/rotur-payments.osl index 53dca44..cab9291 100644 --- a/src/rotur/rotur-payments.osl +++ b/src/rotur/rotur-payments.osl @@ -1,3 +1,5 @@ +auto roturAppOwnerCache = cache.new({capacity: 4, ttl: 3600000}) + // Payments through Rotur payment requests: // https://docs.rotur.dev/build-an-app/payments // diff --git a/src/rotur/rotur-payments.test.osl b/src/rotur/rotur-payments.test.osl index 3b4ba18..ca36d9d 100644 --- a/src/rotur/rotur-payments.test.osl +++ b/src/rotur/rotur-payments.test.osl @@ -9,7 +9,6 @@ string dir = "/tmp/mistwarp-rotur-payments-" ++ timestamp.toStr() ++ "/" void fs.mkdir(dir, {recursive: true}) string purchaseKeysFile = dir ++ "purchase-keys.json" string mistwarpRoturUser = "" -auto roturAppOwnerCache = cache.new({capacity: 4, ttl: 3600000}) def readFile(string path) object ( if !fs.exists(path) ( diff --git a/src/rotur/rotur-signals.osl b/src/rotur/rotur-signals.osl index 76508af..b7839bc 100644 --- a/src/rotur/rotur-signals.osl +++ b/src/rotur/rotur-signals.osl @@ -1,3 +1,5 @@ +auto roturMessageSignals = cache.new({capacity: 10000, ttl: 60000}) + // Rotur's safety signals: https://docs.rotur.dev/build-an-app/safety // // MistWarp declares that people can talk to each other and can spend credits, diff --git a/src/rotur/rotur-signals.test.osl b/src/rotur/rotur-signals.test.osl index 0e2094e..0836718 100644 --- a/src/rotur/rotur-signals.test.osl +++ b/src/rotur/rotur-signals.test.osl @@ -2,7 +2,6 @@ import "std:testing" import "std:json" import "std:cache" -auto roturMessageSignals = cache.new({capacity: 100, ttl: 60000}) def normalizeUsername(string name) string ( return name.toLower().strip() diff --git a/src/rotur/routes.osl b/src/rotur/routes.osl new file mode 100644 index 0000000..1637daf --- /dev/null +++ b/src/rotur/routes.osl @@ -0,0 +1,7 @@ +def registerRoturRoutes(*serve.Router api) ( + api.GET("/perks", handleGetPerks) + api.POST("/perks/refresh", requireAuth, handleRefreshPerks) + api.POST("/perks/seen", requireAuth, handlePerksSeen) + + api.POST("/rotur/webhook", handleRoturWebhook) +) diff --git a/src/storage/routes.osl b/src/storage/routes.osl new file mode 100644 index 0000000..9c73575 --- /dev/null +++ b/src/storage/routes.osl @@ -0,0 +1,24 @@ +def registerBlobRoutes(*serve.Router app) ( + app.GET("/thumbnails/:file", handleGetThumbnail) + app.GET("/blobs/assets/:name", handleGetAssetBlob) + app.GET("/blobs/backpack/:folder/:file", handleGetBackpackBlob) + app.GET("/blobs/projects/:id/project.json", handleGetProjectBlob) + app.GET("/blobs/evidence/:id/project.json", handleGetEvidenceJson) + app.GET("/blobs/evidence/:id/assets/:name", handleGetEvidenceAsset) + if r2Local ( + app.static("/blobs", "./data/blobs") + ) +) + +def registerStorageUploadRoutes(*serve.Router uploads) ( + uploads.POST("/me/backpack", serve.maxBodySize(maxBackpackRequestBytes), requireAuth, requireCurrentAgreement, handleCreateBackpackItem) +) + +def registerStorageRoutes(*serve.Router api) ( + api.GET("/me/backpack", requireAuth, handleGetMyBackpack) + api.PATCH("/me/backpack/:id", requireAuth, handleUpdateBackpackItem) + api.DELETE("/me/backpack/:id", requireAuth, handleDeleteBackpackItem) + api.POST("/me/backpack/delete", requireAuth, handleDeleteBackpackItems) + + api.GET("/projects/:id/workspace.mwp", handleGetProjectWorkspace) +) diff --git a/src/storage/storage-ledger.test.osl b/src/storage/storage-ledger.test.osl index e758d66..cb7c253 100644 --- a/src/storage/storage-ledger.test.osl +++ b/src/storage/storage-ledger.test.osl @@ -102,7 +102,7 @@ def pendingUploadEvents(string username) array ( return quotaPruneEvents((res.data.assertElse(object, {}).pending ?? []).assertElse(array, []), timestamp, quotaWindowMs) ) -import "./utils.osl" +import "../core/utils.osl" import "./storage-addressing.osl" import "./quota-policy.osl" import "./storage-ledger-policy.osl" From 053867c7d2d3a3fbc861a8dcdc8663003408d504 Mon Sep 17 00:00:00 2001 From: Mistium <92952823+Mistium@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:20:54 +0000 Subject: [PATCH 4/8] Give each source file an explicit export list Each file now exports only the names that code in another directory, main.osl or a test elsewhere uses. Everything else is private to its directory, so main.osl can only reach a domain through that surface. --- src/admin/admin-user-policy.osl | 2 + src/admin/admin-users.osl | 6 ++ src/admin/admin.osl | 17 +++++ src/admin/banned-owners.osl | 4 ++ src/admin/errors.osl | 4 ++ src/admin/funnel-events-policy.osl | 2 + src/admin/funnel-events.osl | 2 + src/admin/project-moderation.osl | 14 ++++ src/admin/routes.osl | 4 ++ src/admin/site-error-origins.osl | 2 + src/auth/account-controls.osl | 7 ++ src/auth/agreement.osl | 4 ++ src/auth/auth.osl | 9 +++ src/auth/editor-session-policy.osl | 7 ++ src/auth/privacy-policy.osl | 11 +++ src/auth/routes.osl | 4 ++ src/auth/settings.osl | 2 + src/auth/user-identity.osl | 16 +++++ src/classroom/classroom-billing-policy.osl | 2 + src/classroom/classroom-billing.osl | 2 + src/classroom/classroom-groups.osl | 2 + src/classroom/classroom-legal-policy.osl | 2 + src/classroom/classroom-legal.osl | 4 ++ src/classroom/classroom-policy.osl | 8 +++ src/classroom/classroom-present.osl | 4 ++ src/classroom/classroom-release.osl | 2 + src/classroom/classroom-schools.osl | 2 + src/classroom/classroom-switch-policy.osl | 2 + src/classroom/classroom-switch.osl | 6 ++ src/classroom/classrooms.osl | 8 +++ src/classroom/routes.osl | 4 ++ src/community/bounties.osl | 2 + src/community/challenge-ranking.osl | 2 + src/community/comments.osl | 8 +++ src/community/community-tools.osl | 14 ++++ src/community/development-links.osl | 2 + src/community/development.osl | 5 ++ src/community/groups.osl | 7 ++ src/community/like-milestone-policy.osl | 2 + src/community/like-milestones.osl | 4 ++ src/community/news.osl | 8 +++ src/community/notifications.osl | 6 ++ src/community/remote-milestones.osl | 4 ++ src/community/report-evidence.osl | 7 ++ src/community/reports.osl | 10 +++ src/community/routes.osl | 5 ++ src/community/social.osl | 9 +++ src/community/standing.osl | 7 ++ src/config/config.osl | 81 ++++++++++++++++++++++ src/core/database.osl | 46 ++++++++++++ src/core/utils.osl | 15 ++++ src/games/game-data.osl | 6 ++ src/games/game-inventory.osl | 2 + src/games/game-marketplace.osl | 4 ++ src/games/routes.osl | 4 ++ src/git/git-inspection.osl | 7 ++ src/git/gitea.osl | 2 + src/git/pull-metadata.osl | 4 ++ src/git/pulls.osl | 11 +++ src/git/routes.osl | 4 ++ src/history/commit-management.osl | 2 + src/history/history-delta.osl | 4 ++ src/history/history-migration.osl | 7 ++ src/history/routes.osl | 4 ++ src/http/cache-policy.osl | 5 ++ src/http/middleware.osl | 7 ++ src/http/request-body.osl | 6 ++ src/http/request-limits.osl | 11 +++ src/projects/extensions.osl | 7 ++ src/projects/live-collaboration-policy.osl | 2 + src/projects/live-collaboration.osl | 3 + src/projects/monetize.osl | 17 +++++ src/projects/project-sharing-policy.osl | 4 ++ src/projects/projects.osl | 35 ++++++++++ src/projects/random-projects.osl | 2 + src/projects/related-projects.osl | 2 + src/projects/release-access-policy.osl | 4 ++ src/projects/routes.osl | 5 ++ src/projects/search-ranking.osl | 6 ++ src/realtime/game-realtime.osl | 6 ++ src/realtime/site-realtime.osl | 6 ++ src/rotur/perks.osl | 11 +++ src/rotur/rotur-app.osl | 7 ++ src/rotur/rotur-badges.osl | 5 ++ src/rotur/rotur-payments.osl | 7 ++ src/rotur/rotur-plan.osl | 2 + src/rotur/rotur-signals.osl | 7 ++ src/rotur/rotur-webhooks.osl | 4 ++ src/rotur/routes.osl | 4 ++ src/rotur/upgrade-policy.osl | 2 + src/rotur/vanity-policy.osl | 4 ++ src/storage/archive-security.osl | 4 ++ src/storage/asset-routing.osl | 4 ++ src/storage/backpack-policy.osl | 2 + src/storage/backpack.osl | 6 ++ src/storage/quota-policy.osl | 7 ++ src/storage/r2-snapshot-serving.osl | 4 ++ src/storage/r2-upload-queue.osl | 7 ++ src/storage/r2.osl | 29 ++++++++ src/storage/routes.osl | 6 ++ src/storage/storage-addressing.osl | 7 ++ src/storage/storage-ledger-policy.osl | 4 ++ src/storage/storage-ledger.osl | 18 +++++ src/storage/storage-usage.osl | 5 ++ src/storage/storage.osl | 26 +++++++ src/storage/thumbnail-access.osl | 5 ++ 106 files changed, 785 insertions(+) diff --git a/src/admin/admin-user-policy.osl b/src/admin/admin-user-policy.osl index cd83208..3e9cdeb 100644 --- a/src/admin/admin-user-policy.osl +++ b/src/admin/admin-user-policy.osl @@ -1,3 +1,5 @@ +export {} + def adminReportRole(object report, string key) string ( string subject = (report.subject ?? report.targetUser ?? "").toStr() if subject == "" and report.type.toStr() == "user" ( diff --git a/src/admin/admin-users.osl b/src/admin/admin-users.osl index e94dcb1..68db3da 100644 --- a/src/admin/admin-users.osl +++ b/src/admin/admin-users.osl @@ -1,3 +1,9 @@ +export { + indexComment, + migrateCommentIndex, + unindexComment +} + def adminNotesFile() string ( return "data/admin-notes.json" ) diff --git a/src/admin/admin.osl b/src/admin/admin.osl index c77be4f..2856ba9 100644 --- a/src/admin/admin.osl +++ b/src/admin/admin.osl @@ -1,3 +1,20 @@ +export { + banReasonFor, + banUser, + cachedAdmins, + isBannedUser, + loadAdmins, + loadBans, + projectHiddenFrom, + projectOwnerBanned, + projectsVisibleTo, + projectsVisibleToEveryone, + saveAdmins, + saveBans, + syncRoturBans, + unbanUser +} + auto bansCache = cache.new({capacity: 2, ttl: 300000}) auto adminsCache = cache.new({capacity: 2, ttl: 300000}) diff --git a/src/admin/banned-owners.osl b/src/admin/banned-owners.osl index 254cd21..1abf7c1 100644 --- a/src/admin/banned-owners.osl +++ b/src/admin/banned-owners.osl @@ -1,3 +1,7 @@ +export { + entryOwnerBanned +} + // A banned user's projects are hidden everywhere except from admins. Bans are // kept by username; the set also holds each banned user's ID, so a project // still matches if its stored owner name is out of date after a rename. diff --git a/src/admin/errors.osl b/src/admin/errors.osl index 2026f12..74ed10f 100644 --- a/src/admin/errors.osl +++ b/src/admin/errors.osl @@ -1,3 +1,7 @@ +export { + sweepSiteErrors +} + number siteErrorRetentionMs = 7 * 86400000 array siteErrorPathWords = [ diff --git a/src/admin/funnel-events-policy.osl b/src/admin/funnel-events-policy.osl index 71056f0..36eeba8 100644 --- a/src/admin/funnel-events-policy.osl +++ b/src/admin/funnel-events-policy.osl @@ -1,3 +1,5 @@ +export {} + object funnelEventVariants = { editor_open: [], home_view: [], diff --git a/src/admin/funnel-events.osl b/src/admin/funnel-events.osl index ef1f081..001631a 100644 --- a/src/admin/funnel-events.osl +++ b/src/admin/funnel-events.osl @@ -1,3 +1,5 @@ +export {} + def recordFunnelEvent(string key, number now) ( string id = "funnel:" ++ funnelDayKey(now) sync.lock("funnel-events") diff --git a/src/admin/project-moderation.osl b/src/admin/project-moderation.osl index 7ac2e61..8a4897f 100644 --- a/src/admin/project-moderation.osl +++ b/src/admin/project-moderation.osl @@ -1,3 +1,17 @@ +export { + commentsVisibleTo, + moderationHoldResponse, + projectHiddenByModerator, + projectShadowBanEntry, + refuseIfHiddenByModerator, + removeShadowBannedUser, + renameShadowBannedUser, + reviewsVisibleTo, + shadowBannedSet, + userShadowBanned, + viewerSeesShadowBanned +} + auto shadowBansCache = cache.new({capacity: 2, ttl: 300000}) def loadShadowBans() object ( diff --git a/src/admin/routes.osl b/src/admin/routes.osl index 7418510..b2e58d6 100644 --- a/src/admin/routes.osl +++ b/src/admin/routes.osl @@ -1,3 +1,7 @@ +export { + registerAdminRoutes +} + def registerAdminRoutes(*serve.Router api) ( api.POST("/errors", serve.limitRequests(20, {window: 60000}), handleRecordSiteError) api.GET("/stats", handlePublicStats) diff --git a/src/admin/site-error-origins.osl b/src/admin/site-error-origins.osl index 7ac3062..7b2839a 100644 --- a/src/admin/site-error-origins.osl +++ b/src/admin/site-error-origins.osl @@ -1,3 +1,5 @@ +export {} + array errorReportOrigins = [] for entry in (editorOrigin ++ "," ++ configValue("ERROR_REPORT_ORIGINS", "")).split(",") ( string normalized = siteErrorOrigin(entry, "") diff --git a/src/auth/account-controls.osl b/src/auth/account-controls.osl index 4ed1681..b260033 100644 --- a/src/auth/account-controls.osl +++ b/src/auth/account-controls.osl @@ -1,3 +1,10 @@ +export { + eraseUser, + loadSafety, + userHasBlocked, + usersAreBlocked +} + def safetyPath(string username) string ( return safetyDir ++ normalizeUsername(username) ++ ".json" ) diff --git a/src/auth/agreement.osl b/src/auth/agreement.osl index efdcaa5..ddf64be 100644 --- a/src/auth/agreement.osl +++ b/src/auth/agreement.osl @@ -1,3 +1,7 @@ +export { + requireCurrentAgreement +} + def agreementMetaPath string ( return "data/agreement.json" ) diff --git a/src/auth/auth.osl b/src/auth/auth.osl index 6d5fa5d..03d0d14 100644 --- a/src/auth/auth.osl +++ b/src/auth/auth.osl @@ -1,3 +1,12 @@ +export { + cleanupExpiredSessions, + createSession, + getSession, + isAdminUser, + requestToken, + setAuthCookie +} + auto sessionCache = cache.new({capacity: 10000, ttl: 3600000}) def setAuthCookie(*serve.Context c, string sessionId) ( diff --git a/src/auth/editor-session-policy.osl b/src/auth/editor-session-policy.osl index 1abba72..227baed 100644 --- a/src/auth/editor-session-policy.osl +++ b/src/auth/editor-session-policy.osl @@ -1,3 +1,10 @@ +export { + editorCanDeleteProject, + editorProjectUpdateAllowed, + escapedRequestPath, + scopedRequestAllowed +} + number editorSessionTtlMs = 7200000 number editorSessionMintsPerHour = 60 diff --git a/src/auth/privacy-policy.osl b/src/auth/privacy-policy.osl index b3a67ea..5de4246 100644 --- a/src/auth/privacy-policy.osl +++ b/src/auth/privacy-policy.osl @@ -1,3 +1,14 @@ +export { + activityItemVisible, + activityPrivacy, + activityProjectIds, + effectiveProfileCommentsOff, + effectiveShowRecentActivity, + profileHasChoice, + profileIsMinor, + profileSearchable +} + def roturReportsMinor(object data) boolean ( return data.contains("minor") and data.minor == true ) diff --git a/src/auth/routes.osl b/src/auth/routes.osl index 42267b8..99a2d80 100644 --- a/src/auth/routes.osl +++ b/src/auth/routes.osl @@ -1,3 +1,7 @@ +export { + registerAuthRoutes +} + def registerAuthRoutes(*serve.Router api) ( api.GET("/health", handleHealth) api.GET("/health/dependencies", handleDependencyHealth) diff --git a/src/auth/settings.osl b/src/auth/settings.osl index 82ce50f..25abb25 100644 --- a/src/auth/settings.osl +++ b/src/auth/settings.osl @@ -1,3 +1,5 @@ +export {} + def settingsPath(string username) string ( return userSettingsDir ++ normalizeUsername(username) ++ ".json" ) diff --git a/src/auth/user-identity.osl b/src/auth/user-identity.osl index 9af7891..28121b0 100644 --- a/src/auth/user-identity.osl +++ b/src/auth/user-identity.osl @@ -1,3 +1,19 @@ +export { + discoverUserIdentity, + identityMatches, + loadUserIds, + purgeUser, + purgeUserSessions, + reconcileLoop, + replaceUserInArray, + rewriteNotificationIdentityFields, + roturServiceOptions, + saveUserIds, + userIdForUsername, + userIdsCache, + usernameForId +} + auto userIdsCache = cache.new({capacity: 10000, ttl: 300000}) auto identityRefreshCache = cache.new({capacity: 10000, ttl: 300000}) diff --git a/src/classroom/classroom-billing-policy.osl b/src/classroom/classroom-billing-policy.osl index 2891ee2..a4f989e 100644 --- a/src/classroom/classroom-billing-policy.osl +++ b/src/classroom/classroom-billing-policy.osl @@ -1,3 +1,5 @@ +export {} + number classroomSeatPackSize = 10 number classroomBillingGraceMs = 259200000 number stripeSignatureToleranceSeconds = 300 diff --git a/src/classroom/classroom-billing.osl b/src/classroom/classroom-billing.osl index 0a0f214..c3e0350 100644 --- a/src/classroom/classroom-billing.osl +++ b/src/classroom/classroom-billing.osl @@ -1,3 +1,5 @@ +export {} + def stripeBillingConfigured() boolean ( return stripeSecretKey != "" and stripeWebhookSecret != "" and classroomBillingIntervals(stripeClassroomPrice, stripeClassroomPriceYearly).len > 0 ) diff --git a/src/classroom/classroom-groups.osl b/src/classroom/classroom-groups.osl index b797d95..e21a227 100644 --- a/src/classroom/classroom-groups.osl +++ b/src/classroom/classroom-groups.osl @@ -1,3 +1,5 @@ +export {} + number classroomGroupMinSize = 2 number classroomGroupMaxSize = 6 diff --git a/src/classroom/classroom-legal-policy.osl b/src/classroom/classroom-legal-policy.osl index fbb0df7..5924889 100644 --- a/src/classroom/classroom-legal-policy.osl +++ b/src/classroom/classroom-legal-policy.osl @@ -1,3 +1,5 @@ +export {} + string classroomTermsVersion = "2026-10-01" number classroomRetentionWarnMs = 335 * 86400000 number classroomRetentionDeleteMs = 365 * 86400000 diff --git a/src/classroom/classroom-legal.osl b/src/classroom/classroom-legal.osl index a8ae03a..efd9145 100644 --- a/src/classroom/classroom-legal.osl +++ b/src/classroom/classroom-legal.osl @@ -1,3 +1,7 @@ +export { + eraseClassroomTeacher +} + def classroomTermsRecord(string userId) object ( return (classroomTermsDocs.findById(userId) ?? {}) ) diff --git a/src/classroom/classroom-policy.osl b/src/classroom/classroom-policy.osl index 9c88053..b132526 100644 --- a/src/classroom/classroom-policy.osl +++ b/src/classroom/classroom-policy.osl @@ -1,3 +1,11 @@ +export { + apiRoutePath, + isStudentUserId, + isStudentUsername, + studentProjectUpdateAllowed, + studentRouteAllowed +} + string studentIdPrefix = "mwc_" string studentUsernameSeparator = "~" number studentLoginMaxFailures = 5 diff --git a/src/classroom/classroom-present.osl b/src/classroom/classroom-present.osl index a7d46d2..af9b5f8 100644 --- a/src/classroom/classroom-present.osl +++ b/src/classroom/classroom-present.osl @@ -1,3 +1,7 @@ +export { + presentationViewerRole +} + number classroomPresentationLifetimeMs = 14400000 def activePresentationForProject(string projectId) object ( diff --git a/src/classroom/classroom-release.osl b/src/classroom/classroom-release.osl index 4904c41..987e3a7 100644 --- a/src/classroom/classroom-release.osl +++ b/src/classroom/classroom-release.osl @@ -1,3 +1,5 @@ +export {} + number classroomClaimLifetimeMs = 1209600000 def loadClassroomClaim(string code) object ( diff --git a/src/classroom/classroom-schools.osl b/src/classroom/classroom-schools.osl index bb64230..0557584 100644 --- a/src/classroom/classroom-schools.osl +++ b/src/classroom/classroom-schools.osl @@ -1,3 +1,5 @@ +export {} + def schoolForTeacher(string userId) object ( if userId == "" or isStudentUserId(userId) ( return {} diff --git a/src/classroom/classroom-switch-policy.osl b/src/classroom/classroom-switch-policy.osl index 7b56331..c316ba3 100644 --- a/src/classroom/classroom-switch-policy.osl +++ b/src/classroom/classroom-switch-policy.osl @@ -1,3 +1,5 @@ +export {} + def classroomFlagOn(string value) boolean ( string flag = value.toLower().strip() return flag == "1" or flag == "true" or flag == "yes" or flag == "on" diff --git a/src/classroom/classroom-switch.osl b/src/classroom/classroom-switch.osl index 1fc5e75..47c969a 100644 --- a/src/classroom/classroom-switch.osl +++ b/src/classroom/classroom-switch.osl @@ -1,3 +1,9 @@ +export { + classroomSessionAllowed, + classroomSwitchGuard, + startClassroomTimers +} + boolean classroomEnabled = classroomFlagOn(configValue("CLASSROOM_ENABLED", "")) def classroomTestClass(object classDoc) boolean ( diff --git a/src/classroom/classrooms.osl b/src/classroom/classrooms.osl index 1433ec5..05a096d 100644 --- a/src/classroom/classrooms.osl +++ b/src/classroom/classrooms.osl @@ -1,3 +1,11 @@ +export { + classTeacherRoleForProject, + classesOwnedBy, + classroomAccessGuard, + classroomStudentBenefits, + loadStudent +} + auto classroomPoolCache = cache.new({capacity: 10000, ttl: 60000}) def randomFromAlphabet(string alphabet, number length) string ( diff --git a/src/classroom/routes.osl b/src/classroom/routes.osl index f1fcee7..449f58a 100644 --- a/src/classroom/routes.osl +++ b/src/classroom/routes.osl @@ -1,3 +1,7 @@ +export { + registerClassroomRoutes +} + def registerClassroomRoutes(*serve.Router api) ( api.GET("/classroom/join/:code", serve.limitRequests(30, {window: 60000}), handleClassroomJoinInfo) api.POST("/classroom/login", serve.limitRequests(20, {window: 60000}), handleClassroomLogin) diff --git a/src/community/bounties.osl b/src/community/bounties.osl index 046c826..a15f7b6 100644 --- a/src/community/bounties.osl +++ b/src/community/bounties.osl @@ -1,3 +1,5 @@ +export {} + def bountyCommentKey(string bountyId) string ( return "bounty-" ++ bountyId ) diff --git a/src/community/challenge-ranking.osl b/src/community/challenge-ranking.osl index 670655a..c5e625c 100644 --- a/src/community/challenge-ranking.osl +++ b/src/community/challenge-ranking.osl @@ -1,3 +1,5 @@ +export {} + def rankChallengeEntries(array projects) array ( array ranked = projects.sortBy("rankScore", "descending") for i ranked.len ( diff --git a/src/community/comments.osl b/src/community/comments.osl index 945a721..e5bf62e 100644 --- a/src/community/comments.osl +++ b/src/community/comments.osl @@ -1,3 +1,11 @@ +export { + commentResponse, + createComment, + deleteComment, + editComment, + notifyMentions +} + def createComment(string key, string author, string authorId, string content, string parentId, string kind, number donationAmount, string donationPaymentId) object ( string trimmed = content.strip() if trimmed == "" ( diff --git a/src/community/community-tools.osl b/src/community/community-tools.osl index c8080b8..481d662 100644 --- a/src/community/community-tools.osl +++ b/src/community/community-tools.osl @@ -1,3 +1,17 @@ +export { + creditPlaytimeSession, + migrateSpaceMemberships, + purgeUserCommunityRecords, + purgeUserFromSpaces, + recordProjectDiagnostic, + removeProjectFromSpaces, + removeProjectsFromSpaces, + stringArrayContainsUser, + syncSpaceMemberships, + warmShareCard, + xmlEscape +} + def stringArrayContainsUser(array users, string username) boolean ( string wanted = normalizeUsername(username) for i users.len ( diff --git a/src/community/development-links.osl b/src/community/development-links.osl index 9c840de..f1ea07c 100644 --- a/src/community/development-links.osl +++ b/src/community/development-links.osl @@ -1,3 +1,5 @@ +export {} + // Pure helpers behind the development feed: normalising a GitHub search item // into a feed entry, parsing the pull request references an admin types, and // resolving which pull requests belong to a roadmap entry. Kept separate from diff --git a/src/community/development.osl b/src/community/development.osl index e8477be..0638d71 100644 --- a/src/community/development.osl +++ b/src/community/development.osl @@ -1,3 +1,8 @@ +export { + developmentFeedLoop, + nowSeconds +} + auto developmentCache = cache.new({capacity: 4, ttl: 300000}) // MistWarp's own source lives in public GitHub repositories, so the work in diff --git a/src/community/groups.osl b/src/community/groups.osl index 709dada..5b0bfc8 100644 --- a/src/community/groups.osl +++ b/src/community/groups.osl @@ -1,3 +1,10 @@ +export { + canManageGroupContent, + canManageGroupTag, + groupTagOf, + normalizeGroupTag +} + auto groupSnapshotCache = cache.new({capacity: 10000, ttl: 60000}) def normalizeGroupTag(string value) string ( diff --git a/src/community/like-milestone-policy.osl b/src/community/like-milestone-policy.osl index ed889da..d3f4089 100644 --- a/src/community/like-milestone-policy.osl +++ b/src/community/like-milestone-policy.osl @@ -1,3 +1,5 @@ +export {} + // Only new crossings count. Existing likes are not backfilled on rollout. def crossedLikeMilestones(number before, number after, number notifiedThrough) array ( array res = [] diff --git a/src/community/like-milestones.osl b/src/community/like-milestones.osl index 4a33316..ab0ea77 100644 --- a/src/community/like-milestones.osl +++ b/src/community/like-milestones.osl @@ -1,3 +1,7 @@ +export { + notifyLikeMilestones +} + // Claims survive inbox clearing and server restarts. Serialize claims for the // same item so simultaneous reactions cannot send the same milestone twice. def notifyLikeMilestones(string target, string kind, string contentId, number before, number after, string path, object extra) ( diff --git a/src/community/news.osl b/src/community/news.osl index 6214744..0f6697d 100644 --- a/src/community/news.osl +++ b/src/community/news.osl @@ -1,3 +1,11 @@ +export { + isValidReaction, + loadNews, + migratePolicyNewsPosts, + purgeUserFromNews, + renameUserInNews +} + def loadNews() array ( auto res = readFile(newsFile) if !res.ok ( diff --git a/src/community/notifications.osl b/src/community/notifications.osl index 0a70d1b..c1ad591 100644 --- a/src/community/notifications.osl +++ b/src/community/notifications.osl @@ -1,3 +1,9 @@ +export { + addNotification, + loadNotifications, + saveNotifications +} + def loadNotifications(string username) array ( string key = normalizeUsername(username) if !isSafePathPart(key) ( diff --git a/src/community/remote-milestones.osl b/src/community/remote-milestones.osl index 5ca3857..fa3aea7 100644 --- a/src/community/remote-milestones.osl +++ b/src/community/remote-milestones.osl @@ -1,3 +1,7 @@ +export { + remoteMilestoneChecks +} + auto remoteMilestoneChecks = cache.new({capacity: 10000, ttl: 10000}) // Use existing public Rotur APIs. Counts, recipients and links never come diff --git a/src/community/report-evidence.osl b/src/community/report-evidence.osl index ae3672b..6b061fa 100644 --- a/src/community/report-evidence.osl +++ b/src/community/report-evidence.osl @@ -1,3 +1,10 @@ +export { + handleGetEvidenceAsset, + handleGetEvidenceJson, + loadEvidenceMeta, + maybeClearProjectEvidence +} + def evidenceDirFor(string id) string ( return reportEvidenceDir ++ id ++ "/" ) diff --git a/src/community/reports.osl b/src/community/reports.osl index b947139..712e4f0 100644 --- a/src/community/reports.osl +++ b/src/community/reports.osl @@ -1,3 +1,13 @@ +export { + anonymiseReportsBy, + closeRoturReport, + fileUnfiledRoturReports, + loadReports, + reportedContent, + saveReports, + syncEscalatedRoturReports +} + def loadReports() array ( auto res = readFile(reportsFile) if !res.ok ( diff --git a/src/community/routes.osl b/src/community/routes.osl index 3166d56..ad0922c 100644 --- a/src/community/routes.osl +++ b/src/community/routes.osl @@ -1,3 +1,8 @@ +export { + registerCommunityRoutes, + registerCommunityUploadRoutes +} + def registerCommunityUploadRoutes(*serve.Router uploads) ( uploads.POST("/spaces/:id/thumbnail", serve.maxBodySize(maxThumbnailRequestBytes), requireAuth, handleSetSpaceThumbnail) ) diff --git a/src/community/social.osl b/src/community/social.osl index 4a6b783..4b00137 100644 --- a/src/community/social.osl +++ b/src/community/social.osl @@ -1,3 +1,12 @@ +export { + addPurchaseRecord, + addToLibrary, + isInLibrary, + ownerIndexCount, + ownerIndexEntries, + usernameIsMinor +} + def sortProjectsByEditedDesc(array items) array ( array sorted = [] for i items.len ( diff --git a/src/community/standing.osl b/src/community/standing.osl index 8b2ee26..bf54e71 100644 --- a/src/community/standing.osl +++ b/src/community/standing.osl @@ -1,3 +1,10 @@ +export { + requireGoodStanding, + setStanding, + standingInfo, + standingLevel +} + def standingRank(string level) number ( if level == "warning" ( return 1 diff --git a/src/config/config.osl b/src/config/config.osl index 4da4553..8219c38 100644 --- a/src/config/config.osl +++ b/src/config/config.osl @@ -1,3 +1,84 @@ +export { + activityDir, + adminUsers, + adminsFile, + agreementAcceptDir, + appURL, + assetsIndexFile, + backpackDir, + bansFile, + cardsDir, + commentsDir, + commerceServiceKey, + configValue, + editorOrigin, + extensionsDir, + extensionsFile, + gameEntitlementsDir, + gameInventoryDir, + gameSaveUsageDir, + gameSavesDir, + giteaAdminToken, + giteaURL, + githubOrg, + githubToken, + historyMigrationWorkers, + indexFile, + legacyAccountGameDataDir, + localBlobsDir, + maxAssetCount, + maxExtensionSourceBytes, + maxProjectExtensionBytes, + maxProjectExtensions, + maxProjectJsonBytes, + maxStoredProjectJsonBytes, + maxThumbBytes, + maxUploadChunkBytes, + maxWorkspaceBytes, + maxWorkspaceFiles, + mistwarpRoturToken, + mistwarpRoturUser, + newsFile, + notificationsDir, + port, + projectsDir, + purchaseKeysFile, + quotaDir, + quotaWindowMs, + r2AccessKeyId, + r2Bucket, + r2Client, + r2Endpoint, + r2Local, + r2PublicBase, + r2SecretAccessKey, + realtimeURL, + releasesDir, + reportEvidenceDir, + reportsDir, + reportsFile, + roturAppKey, + roturBase, + safetyDir, + sessionsDir, + shadowBansFile, + stagingDir, + stripeClassroomPrice, + stripeClassroomPriceYearly, + stripeSeatPackPrice, + stripeSeatPackPriceYearly, + stripeSecretKey, + stripeWebhookSecret, + thumbnailSecretFile, + thumbnailsDir, + tmpDir, + trashDir, + uploadDebounceMs, + userIdsFile, + userSettingsDir, + usersDir +} + def dotenvValue(string name) string ( if !fs.exists(".env") ( return "" diff --git a/src/core/database.osl b/src/core/database.osl index 1e36ccd..f4c4baf 100644 --- a/src/core/database.osl +++ b/src/core/database.osl @@ -1,5 +1,51 @@ import "std:process" +export { + assignmentDocs, + bountyWorkerDocs, + classDocs, + classroomAuditDocs, + classroomClaimDocs, + classroomGroupDocs, + classroomPlanDocs, + classroomPresentationDocs, + classroomTermsDocs, + cleanupExpiredCommunityRecords, + commentIndexDocs, + deleteDocumentsByField, + deleteProjectCommunityRecords, + developmentDocs, + diagnosticDocs, + errorDocs, + feedbackDocs, + funnelDocs, + ideaDocs, + initCommunityDatabase, + likeMilestoneDocs, + playtimeDocs, + playtimeSessionDocs, + previewDocs, + profileSearchDocs, + projectLastPlayed, + projectPlaytime, + projectSearchDocs, + projectTotalPlaytime, + pullDocs, + putDocument, + releaseDocs, + removeSearchProject, + reviewDocs, + schoolDocs, + spaceDocs, + spaceMemberDocs, + studentDocs, + submissionDocs, + syncSearchProfile, + syncSearchProject, + validPreviewKey +} + + // The database and its collections are opened once at startup. A failure here // means the API cannot serve anything, so report the reason and stop instead of // carrying on with a half-initialized database. diff --git a/src/core/utils.osl b/src/core/utils.osl index 12785a5..732dc96 100644 --- a/src/core/utils.osl +++ b/src/core/utils.osl @@ -1,3 +1,18 @@ +export { + clampNumber, + copyFileReplacing, + extPartOf, + generateCommentId, + generateId, + isSafePathPart, + isValidMd5Ext, + md5PartOf, + normalizeTag, + normalizeUsername, + paginate, + randomString +} + def randomString(number length) string ( return crypto.randomString(length) ) diff --git a/src/games/game-data.osl b/src/games/game-data.osl index a9453fe..64d6cf1 100644 --- a/src/games/game-data.osl +++ b/src/games/game-data.osl @@ -1,3 +1,9 @@ +export { + collectGameDataForUser, + purgeGameDataForProject, + purgeGameDataForUser +} + auto gameDataCapabilityCache = cache.new({capacity: 100000, ttl: 300000}) number gameSaveMaxBytes = 65536 diff --git a/src/games/game-inventory.osl b/src/games/game-inventory.osl index dbe8725..d40dc68 100644 --- a/src/games/game-inventory.osl +++ b/src/games/game-inventory.osl @@ -1,3 +1,5 @@ +export {} + def projectGameItems(object project) array ( if project.contains("gameItems") and project.gameItems != null ( return project.gameItems.assertElse(array, []) diff --git a/src/games/game-marketplace.osl b/src/games/game-marketplace.osl index 111ed54..a59d413 100644 --- a/src/games/game-marketplace.osl +++ b/src/games/game-marketplace.osl @@ -1,3 +1,7 @@ +export { + completeGameProductPurchase +} + def projectGameProducts(object project) array ( if project.contains("gameProducts") and project.gameProducts != null ( return project.gameProducts.assertElse(array, []) diff --git a/src/games/routes.osl b/src/games/routes.osl index 12fe76c..51be651 100644 --- a/src/games/routes.osl +++ b/src/games/routes.osl @@ -1,3 +1,7 @@ +export { + registerGameRoutes +} + def registerGameRoutes(*serve.Router api) ( api.GET("/me/game-saves", requireAuth, handleListMyGameSaves) api.GET("/me/game-inventory", requireAuth, handleGetMyGameInventory) diff --git a/src/git/git-inspection.osl b/src/git/git-inspection.osl index 7db19f6..c7dc3a8 100644 --- a/src/git/git-inspection.osl +++ b/src/git/git-inspection.osl @@ -1,3 +1,10 @@ +export { + cachedProjectWorkspacePath, + projectRecordsCommit, + projectWorkspaceFingerprint, + validGitObjectId +} + auto gitInspectionPruneCache = cache.new({capacity: 4, ttl: 300000}) def projectWorkspaceFingerprint(object project) string ( diff --git a/src/git/gitea.osl b/src/git/gitea.osl index 9bd0546..b6a3831 100644 --- a/src/git/gitea.osl +++ b/src/git/gitea.osl @@ -1,3 +1,5 @@ +export {} + def giteaHeaders(string sudoUser) object ( object headers = { "Authorization": "token " ++ giteaAdminToken, diff --git a/src/git/pull-metadata.osl b/src/git/pull-metadata.osl index 2c5ffbd..7cab885 100644 --- a/src/git/pull-metadata.osl +++ b/src/git/pull-metadata.osl @@ -1,3 +1,7 @@ +export { + canonicalHistoryHead +} + def storedPullCommentCount(object pull) number ( if !pull.contains("commentCount") or pull.commentCount == null ( return -1 diff --git a/src/git/pulls.osl b/src/git/pulls.osl index ce11134..1a646e0 100644 --- a/src/git/pulls.osl +++ b/src/git/pulls.osl @@ -1,3 +1,14 @@ +export { + canAccessPullWorkspace, + completeNativePull, + createNativePull, + projectBranchHead, + projectGitHead, + projectHasWorkspace, + pullCommentKey, + pullDocument +} + def projectGitHead(object project) string ( return canonicalHistoryHead(project) ) diff --git a/src/git/routes.osl b/src/git/routes.osl index cd76787..6e4ee46 100644 --- a/src/git/routes.osl +++ b/src/git/routes.osl @@ -1,3 +1,7 @@ +export { + registerGitRoutes +} + def registerGitRoutes(*serve.Router api) ( api.GET("/projects/:id/commits", handleGetCommits) api.GET("/projects/:id/branches", handleListProjectBranches) diff --git a/src/history/commit-management.osl b/src/history/commit-management.osl index 8dd7596..9ab7709 100644 --- a/src/history/commit-management.osl +++ b/src/history/commit-management.osl @@ -1,3 +1,5 @@ +export {} + def projectCommitMetadata(object project) object ( if project.contains("commitMetadata") and project.commitMetadata != null ( return project.commitMetadata.assertElse(object, {}) diff --git a/src/history/history-delta.osl b/src/history/history-delta.osl index 2047166..ff25cd1 100644 --- a/src/history/history-delta.osl +++ b/src/history/history-delta.osl @@ -1,3 +1,7 @@ +export { + graphBranchLog +} + def appendUniqueCommit(array target, object seen, object commit, number limit) array ( string sha = commit.sha.toStr() if sha != "" and !seen.contains(sha) and target.len < limit ( diff --git a/src/history/history-migration.osl b/src/history/history-migration.osl index 14f0421..a7acc2e 100644 --- a/src/history/history-migration.osl +++ b/src/history/history-migration.osl @@ -1,3 +1,10 @@ +export { + ensureGeneratedProjectHistory, + ensureUploadedProjectHistory, + generateMissingProjectHistories, + projectHasStoredContent +} + def writeGeneratedHistoryArchive(string projectJsonPath, string assetsPath, string outputPath, string projectId, string owner, number createdAt, string parentArchivePath, string parentHead, string parentId) string ( return gitinspection.GenerateHistoryArchiveJSON(projectJsonPath, assetsPath, outputPath, projectId, owner, createdAt, parentArchivePath, parentHead, parentId) ) diff --git a/src/history/routes.osl b/src/history/routes.osl index 848bfd3..a2c8352 100644 --- a/src/history/routes.osl +++ b/src/history/routes.osl @@ -1,3 +1,7 @@ +export { + registerHistoryRoutes +} + def registerHistoryRoutes(*serve.Router api) ( api.PATCH("/projects/:id/commits/:sha", requireAuth, handlePatchCommit) api.GET("/projects/:id/commits/:sha/co-authors", handleGetCommitCollaborators) diff --git a/src/http/cache-policy.osl b/src/http/cache-policy.osl index 95d7e35..eb29e5f 100644 --- a/src/http/cache-policy.osl +++ b/src/http/cache-policy.osl @@ -1,3 +1,8 @@ +export { + projectWorkspaceEtag, + shouldPruneGitInspectionCache +} + def shouldPruneGitInspectionCache(number callCount) boolean ( return callCount > 0 and ((callCount - 1) % 32) == 0 ) diff --git a/src/http/middleware.osl b/src/http/middleware.osl index 4bea83b..fc72934 100644 --- a/src/http/middleware.osl +++ b/src/http/middleware.osl @@ -1,3 +1,10 @@ +export { + authMiddleware, + editorSessionGuard, + requireAdmin, + requireAuth +} + def authMiddleware(*serve.Context c) ( boolean authenticated = false string username = "" diff --git a/src/http/request-body.osl b/src/http/request-body.osl index 2c43e41..ed4b06c 100644 --- a/src/http/request-body.osl +++ b/src/http/request-body.osl @@ -1,3 +1,9 @@ +export { + contextBool, + contextString, + requestJSON +} + def requestJSON(*serve.Context c) object ( object body = c.bodyJSON() catch (err) ( if err.kind == "ParseError" ( diff --git a/src/http/request-limits.osl b/src/http/request-limits.osl index 4e370e2..f64f7e4 100644 --- a/src/http/request-limits.osl +++ b/src/http/request-limits.osl @@ -1,3 +1,14 @@ +export { + maxAppRequestBytes, + maxBackpackRequestBytes, + maxExtensionIndexRequestBytes, + maxProjectUploadRequestBytes, + maxStandardRequestBytes, + maxThumbnailRequestBytes, + maxUploadChunkRequestBytes, + maxWorkspaceBootstrapRequestBytes +} + number maxAppRequestBytes = 1073741824 number maxStandardRequestBytes = 262144 number maxProjectUploadRequestBytes = 1073741824 diff --git a/src/projects/extensions.osl b/src/projects/extensions.osl index a3d2e18..0866c23 100644 --- a/src/projects/extensions.osl +++ b/src/projects/extensions.osl @@ -1,3 +1,10 @@ +export { + migrateExtensionUsage, + projectBlockedExtension, + storeProjectExtensions, + storedProjectExtensions +} + def loadExtensionPolicy() object ( auto res = readFile(extensionsFile) if !res.ok ( diff --git a/src/projects/live-collaboration-policy.osl b/src/projects/live-collaboration-policy.osl index ec34aab..229acdb 100644 --- a/src/projects/live-collaboration-policy.osl +++ b/src/projects/live-collaboration-policy.osl @@ -1,3 +1,5 @@ +export {} + def isSharedEditorRole(string role) boolean ( return role == "owner" or role == "maintainer" or role == "editor" ) diff --git a/src/projects/live-collaboration.osl b/src/projects/live-collaboration.osl index c6df7d7..0feb977 100644 --- a/src/projects/live-collaboration.osl +++ b/src/projects/live-collaboration.osl @@ -1,5 +1,8 @@ import "./live-collaboration-policy.osl" +export {} + + // Temporary approvals are leases; explicitly shared Editor access persists. auto liveEditorSessions = cache.new({capacity: 10000, ttl: 90000}) auto projectEditorPresence = cache.new({capacity: 10000, ttl: 90000}) diff --git a/src/projects/monetize.osl b/src/projects/monetize.osl index 278b685..6126078 100644 --- a/src/projects/monetize.osl +++ b/src/projects/monetize.osl @@ -1,3 +1,20 @@ +export { + awardContributionBounty, + canSeeInsideProject, + completeProjectPurchase, + loadPurchaseKeys, + projectCommerceSplits, + projectIsViewable, + projectRevenue, + projectSaves, + projectVisibility, + prunePurchaseKeys, + refuseOldPaymentClient, + renameUserMonetization, + savePurchaseKeys, + sourcePolicyAllowsViewer +} + def projectVisibility(object project) string ( if project.contains("visibility") and project.visibility != null ( string v = project.visibility.toStr() diff --git a/src/projects/project-sharing-policy.osl b/src/projects/project-sharing-policy.osl index c441207..af1edd0 100644 --- a/src/projects/project-sharing-policy.osl +++ b/src/projects/project-sharing-policy.osl @@ -1,3 +1,7 @@ +export { + projectSourcePolicyAllows +} + def projectSourcePolicyAllows(object project, boolean isOwner) boolean ( if isOwner ( return true diff --git a/src/projects/projects.osl b/src/projects/projects.osl index 4673e68..f4f64af 100644 --- a/src/projects/projects.osl +++ b/src/projects/projects.osl @@ -1,3 +1,38 @@ +export { + bannedKeyMatches, + canEditProject, + canViewProject, + canViewProjectJson, + cleanupExpiredStagedUploadParts, + deleteOrphanedAssets, + ensureProjectJsonSnapshot, + ensureProjectWorkspaceSnapshots, + ensureViewKey, + forkFailureResponse, + forkProjectFor, + ownerStorageBytes, + pendingUploadEvents, + projectJsonAccess, + projectMustStayPrivate, + projectResponse, + projectRole, + projectVersionHistory, + purgeProject, + purgeProjects, + quotaPath, + removeProjectStorageKeyIfOrphaned, + removeString, + roturMembershipUrl, + roturStorageFullResponse, + sharedProjectsCache, + storageDetails, + storageFullResponse, + sweepStagedProjects, + trashedProjectsOwnedBy, + validateUploadedHistory, + vanityLapseLoop +} + auto viewDedup = cache.new({capacity: 100000, ttl: 21600000}) auto viewIndexFlush = cache.new({capacity: 100000, ttl: 600000}) auto sharedProjectsCache = cache.new({capacity: 2, ttl: 60000}) diff --git a/src/projects/random-projects.osl b/src/projects/random-projects.osl index 94e1adf..29165b5 100644 --- a/src/projects/random-projects.osl +++ b/src/projects/random-projects.osl @@ -1,3 +1,5 @@ +export {} + def randomProjectEntry(array entries, string exclude) object ( array candidates = [] for i entries.len ( diff --git a/src/projects/related-projects.osl b/src/projects/related-projects.osl index 53041c6..1416b38 100644 --- a/src/projects/related-projects.osl +++ b/src/projects/related-projects.osl @@ -1,3 +1,5 @@ +export {} + def relatedProjectEntries(array entries, string id, array excluded, number limit) array ( object seen = {} seen[id] = true diff --git a/src/projects/release-access-policy.osl b/src/projects/release-access-policy.osl index 1655485..326a60e 100644 --- a/src/projects/release-access-policy.osl +++ b/src/projects/release-access-policy.osl @@ -1,3 +1,7 @@ +export { + publicReleaseRecord +} + def keyedJsonUrl(string url, object access) string ( if url == "" or access.allowed != true ( return "" diff --git a/src/projects/routes.osl b/src/projects/routes.osl index eccaa12..30341cc 100644 --- a/src/projects/routes.osl +++ b/src/projects/routes.osl @@ -1,3 +1,8 @@ +export { + registerProjectRoutes, + registerProjectUploadRoutes +} + def registerProjectUploadRoutes(*serve.Router uploads) ( uploads.POST("/projects/:id/upload", serve.maxBodySize(maxProjectUploadRequestBytes), requireAuth, requireCurrentAgreement, handleUploadProject) uploads.POST("/projects/:id/upload-parts/:token/chunk", serve.maxBodySize(maxUploadChunkRequestBytes), requireAuth, requireCurrentAgreement, handleUploadProjectPartChunk) diff --git a/src/projects/search-ranking.osl b/src/projects/search-ranking.osl index 2110167..f2d1e85 100644 --- a/src/projects/search-ranking.osl +++ b/src/projects/search-ranking.osl @@ -1,3 +1,9 @@ +export { + searchMatchScore, + searchRank, + spaceSearchScore +} + def searchMatchScore(string text, string query) number ( string value = text.toLower().strip() if value == "" or query == "" ( diff --git a/src/realtime/game-realtime.osl b/src/realtime/game-realtime.osl index 01f07bb..185a0ca 100644 --- a/src/realtime/game-realtime.osl +++ b/src/realtime/game-realtime.osl @@ -1,3 +1,9 @@ +export { + multiplayerTicketCache, + realtimeUpgradeGuard, + setupGameRealtime +} + auto multiplayerTicketCache = cache.new({capacity: 100000, ttl: 120000}) auto realtimeHandshakeCache = cache.new({capacity: 100000, ttl: 30000}) diff --git a/src/realtime/site-realtime.osl b/src/realtime/site-realtime.osl index 7f86f4b..3bafc5b 100644 --- a/src/realtime/site-realtime.osl +++ b/src/realtime/site-realtime.osl @@ -1,3 +1,9 @@ +export { + setupSiteRealtime, + siteBroadcastProject, + siteBroadcastStats +} + def siteSend(*ws.Connection conn, object message) ( conn.Send(json.stringify(message)) ) diff --git a/src/rotur/perks.osl b/src/rotur/perks.osl index 486fcc7..e55ee3d 100644 --- a/src/rotur/perks.osl +++ b/src/rotur/perks.osl @@ -1,3 +1,14 @@ +export { + mistwarpBenefitsForProject, + mistwarpBenefitsForTier, + mistwarpBenefitsForUserId, + mistwarpBenefitsForUsername, + roturTierLookup, + trimDayHistory, + trimTimedEntries, + vanityEligibility +} + auto paidPerksCache = cache.new({capacity: 10000, ttl: 300000}) auto lastKnownTiers = cache.new({capacity: 10000, ttl: 2592000000}) auto roturTierUnreachable = cache.new({capacity: 10000, ttl: 60000}) diff --git a/src/rotur/rotur-app.osl b/src/rotur/rotur-app.osl index 36fc012..33f455e 100644 --- a/src/rotur/rotur-app.osl +++ b/src/rotur/rotur-app.osl @@ -1,3 +1,10 @@ +export { + roturApp, + roturAppReady, + roturClientId, + roturUserRef +} + // MistWarp's Rotur App, called with its client ID and secret: // https://docs.rotur.dev/build-an-app/apps-api // diff --git a/src/rotur/rotur-badges.osl b/src/rotur/rotur-badges.osl index e1f236d..90f4c73 100644 --- a/src/rotur/rotur-badges.osl +++ b/src/rotur/rotur-badges.osl @@ -1,3 +1,8 @@ +export { + recordCreatingDay, + roturBadgeEvent +} + // Badges on people's Rotur profiles, given by MistWarp's Rotur App: // https://docs.rotur.dev/build-an-app/badges // diff --git a/src/rotur/rotur-payments.osl b/src/rotur/rotur-payments.osl index cab9291..ee20d26 100644 --- a/src/rotur/rotur-payments.osl +++ b/src/rotur/rotur-payments.osl @@ -1,3 +1,10 @@ +export { + forgetPurchase, + mistwarpFeeAccount, + paidPurchase, + startPurchase +} + auto roturAppOwnerCache = cache.new({capacity: 4, ttl: 3600000}) // Payments through Rotur payment requests: diff --git a/src/rotur/rotur-plan.osl b/src/rotur/rotur-plan.osl index 9dad3bb..d6f101c 100644 --- a/src/rotur/rotur-plan.osl +++ b/src/rotur/rotur-plan.osl @@ -1,3 +1,5 @@ +export {} + def normalizeRoturTier(string rawTier) string ( string tier = rawTier.toLower().strip() if tier == "lite" ( diff --git a/src/rotur/rotur-signals.osl b/src/rotur/rotur-signals.osl index b7839bc..c44ba92 100644 --- a/src/rotur/rotur-signals.osl +++ b/src/rotur/rotur-signals.osl @@ -1,3 +1,10 @@ +export { + commentRefusal, + isMessageNotification, + roturMessageRefusal, + roturPurchaseRefusal +} + auto roturMessageSignals = cache.new({capacity: 10000, ttl: 60000}) // Rotur's safety signals: https://docs.rotur.dev/build-an-app/safety diff --git a/src/rotur/rotur-webhooks.osl b/src/rotur/rotur-webhooks.osl index 5402976..4f6f9b6 100644 --- a/src/rotur/rotur-webhooks.osl +++ b/src/rotur/rotur-webhooks.osl @@ -1,3 +1,7 @@ +export { + processRoturWebhooks +} + // Webhooks from MistWarp's Rotur App: https://docs.rotur.dev/build-an-app/webhooks // // Rotur tells MistWarp when someone deletes their Rotur account (user.deleted), diff --git a/src/rotur/routes.osl b/src/rotur/routes.osl index 1637daf..9250685 100644 --- a/src/rotur/routes.osl +++ b/src/rotur/routes.osl @@ -1,3 +1,7 @@ +export { + registerRoturRoutes +} + def registerRoturRoutes(*serve.Router api) ( api.GET("/perks", handleGetPerks) api.POST("/perks/refresh", requireAuth, handleRefreshPerks) diff --git a/src/rotur/upgrade-policy.osl b/src/rotur/upgrade-policy.osl index dc51db5..36361da 100644 --- a/src/rotur/upgrade-policy.osl +++ b/src/rotur/upgrade-policy.osl @@ -1,3 +1,5 @@ +export {} + def tierRank(string tier) number ( string key = tier.toLower().strip() if key == "lite" ( diff --git a/src/rotur/vanity-policy.osl b/src/rotur/vanity-policy.osl index 70810d2..2304e37 100644 --- a/src/rotur/vanity-policy.osl +++ b/src/rotur/vanity-policy.osl @@ -1,3 +1,7 @@ +export { + vanityAfter +} + // A vanity project URL needs a plan that includes one. Once the owner's plan // stops including it, the URL stops working but stays reserved for them for // 30 days, so resubscribing brings it straight back. After that it is diff --git a/src/storage/archive-security.osl b/src/storage/archive-security.osl index d1216e3..8b4ec17 100644 --- a/src/storage/archive-security.osl +++ b/src/storage/archive-security.osl @@ -1,3 +1,7 @@ +export { + validateZipArchive +} + def validateZipArchive(byte[] body, number maxCompressedBytes, number maxExpandedBytes, number maxFiles, boolean readContents) boolean ( return storagefs.ValidateZipArchive(body, maxCompressedBytes, maxExpandedBytes, maxFiles, readContents) ) diff --git a/src/storage/asset-routing.osl b/src/storage/asset-routing.osl index 2b55b29..af1ad27 100644 --- a/src/storage/asset-routing.osl +++ b/src/storage/asset-routing.osl @@ -1,3 +1,7 @@ +export { + assetNeedsUpload +} + def assetUrlForLocation(string name, boolean localExists, string localAssetsBase, string r2PublicBase) string ( if localExists or r2PublicBase == "" ( return localAssetsBase ++ "/" ++ name diff --git a/src/storage/backpack-policy.osl b/src/storage/backpack-policy.osl index 922a087..0101ce1 100644 --- a/src/storage/backpack-policy.osl +++ b/src/storage/backpack-policy.osl @@ -1,3 +1,5 @@ +export {} + number maxBackpackBodyBytes = 20971520 number maxBackpackThumbnailBytes = 1048576 number maxBackpackItems = 2000 diff --git a/src/storage/backpack.osl b/src/storage/backpack.osl index 8fd8d62..bbd17a1 100644 --- a/src/storage/backpack.osl +++ b/src/storage/backpack.osl @@ -1,3 +1,9 @@ +export { + backpackStoredBytes, + collectBackpackForUser, + purgeBackpackForUser +} + def backpackUserKey(string userId) string ( return ("backpack:" ++ userId).hashSHA256() ) diff --git a/src/storage/quota-policy.osl b/src/storage/quota-policy.osl index f930b5d..5a1e6b6 100644 --- a/src/storage/quota-policy.osl +++ b/src/storage/quota-policy.osl @@ -1,3 +1,10 @@ +export { + quotaPruneEvents, + quotaTotalBytes, + quotaWithoutNames, + storageAllowance +} + def quotaPruneEvents(array events, number nowMs, number windowMs) array ( array kept = [] for i events.len ( diff --git a/src/storage/r2-snapshot-serving.osl b/src/storage/r2-snapshot-serving.osl index 37a8a20..1e3e943 100644 --- a/src/storage/r2-snapshot-serving.osl +++ b/src/storage/r2-snapshot-serving.osl @@ -1,3 +1,7 @@ +export { + serveProjectJsonStorageKey +} + def serveProjectJsonStorageKey(*serve.Context c, string key, boolean gzip) ( if !isSafeBlobKey(key) ( c.notFound("project snapshot not found") diff --git a/src/storage/r2-upload-queue.osl b/src/storage/r2-upload-queue.osl index f96fcd5..d93eaba 100644 --- a/src/storage/r2-upload-queue.osl +++ b/src/storage/r2-upload-queue.osl @@ -1,3 +1,10 @@ +export { + flushR2UploadQueue, + loadR2UploadQueue, + projectStorageActivityAt, + stageR2Blob +} + def projectStorageActivityAt(object project) number ( number edited = (project.edited ?? 0).toNum() number created = (project.created ?? 0).toNum() diff --git a/src/storage/r2.osl b/src/storage/r2.osl index 64e9ed4..7e78a95 100644 --- a/src/storage/r2.osl +++ b/src/storage/r2.osl @@ -1,3 +1,32 @@ +export { + assetObjectKey, + assetsBaseUrl, + contentTypeForExt, + copyProjectWorkspaceToPath, + copyStorageKeyToPath, + copyStoredProjectJsonToPath, + keyedThumbUrlFor, + localBlobPath, + projectJsonBaseUrl, + projectJsonIsGzip, + projectJsonKey, + projectJsonUrlFor, + projectViewKey, + r2PutEncoded, + r2Remove, + r2StorageIndexFile, + serveBlobKey, + serveNotModified, + serveProjectJson, + stagingJsonPath, + syncR2StorageInventory, + thumbUrlFor, + thumbnailFileFor, + thumbnailPath, + viewerThumbEntries, + viewerThumbUrlFor +} + def projectJsonKey(string id) string ( return "projects/" ++ id ++ "/project.json" ) diff --git a/src/storage/routes.osl b/src/storage/routes.osl index 9c73575..353fcbc 100644 --- a/src/storage/routes.osl +++ b/src/storage/routes.osl @@ -1,3 +1,9 @@ +export { + registerBlobRoutes, + registerStorageRoutes, + registerStorageUploadRoutes +} + def registerBlobRoutes(*serve.Router app) ( app.GET("/thumbnails/:file", handleGetThumbnail) app.GET("/blobs/assets/:name", handleGetAssetBlob) diff --git a/src/storage/storage-addressing.osl b/src/storage/storage-addressing.osl index 819ae59..dd7c805 100644 --- a/src/storage/storage-addressing.osl +++ b/src/storage/storage-addressing.osl @@ -1,3 +1,10 @@ +export { + contentHash, + projectJsonSnapshotKey, + projectWorkspaceKeys, + projectWorkspaceSnapshotKey +} + def projectJsonSnapshotKey(string hash) string ( return "project-json/" ++ hash ++ ".json.gz" ) diff --git a/src/storage/storage-ledger-policy.osl b/src/storage/storage-ledger-policy.osl index ace9280..1b4c6bf 100644 --- a/src/storage/storage-ledger-policy.osl +++ b/src/storage/storage-ledger-policy.osl @@ -1,3 +1,7 @@ +export { + projectStorageSummary +} + // What MistWarp counts against a person's one Rotur storage allowance, which // the Rotur API keeps in its storage ledger. // diff --git a/src/storage/storage-ledger.osl b/src/storage/storage-ledger.osl index 93268b7..bf92121 100644 --- a/src/storage/storage-ledger.osl +++ b/src/storage/storage-ledger.osl @@ -1,3 +1,21 @@ +export { + ledgerAllowance, + ledgerFreeAll, + logLedgerSetup, + noStorageHold, + pendingAssetHold, + projectOwnerId, + releasePendingAssetHolds, + storageHold, + storageHoldRelease, + storageReport, + storageSettle, + storageSettleLoop, + storageSettleProject, + storageSettleSoon, + usesRoturStorage +} + // MistWarp's projects, assets and backpack count against each person's one // Rotur storage allowance (storage-ledger-policy.osl says what counts). The // Rotur API keeps the count in its storage ledger: diff --git a/src/storage/storage-usage.osl b/src/storage/storage-usage.osl index 7d444d0..531b25c 100644 --- a/src/storage/storage-usage.osl +++ b/src/storage/storage-usage.osl @@ -1,3 +1,8 @@ +export { + backfillProjectHistoryBytes, + backfillReleaseJsonBytes +} + def storedKeyBytes(string key, object inventory) number ( string path = localBlobPath(key) if fs.exists(path) ( diff --git a/src/storage/storage.osl b/src/storage/storage.osl index d98ef96..db1e85e 100644 --- a/src/storage/storage.osl +++ b/src/storage/storage.osl @@ -1,3 +1,29 @@ +export { + addActivity, + appendComment, + deleteProjectFile, + ensureDirectories, + indexEntryFromProject, + loadActivity, + loadAssetsIndex, + loadComments, + loadProfile, + loadProject, + profileExists, + projectHistoryBytes, + projectSizeBytes, + projectStorageBytes, + readFile, + refreshIndexUrls, + removeFromIndex, + saveAssetsIndex, + saveComments, + saveProfile, + saveProject, + upsertIndexEntry, + writeFile +} + def ensureDirectories() boolean ( boolean res = fs.mkdir(projectsDir, {recursive: true}) res = res and fs.mkdir(usersDir, {recursive: true}) diff --git a/src/storage/thumbnail-access.osl b/src/storage/thumbnail-access.osl index 73e5536..39ce02d 100644 --- a/src/storage/thumbnail-access.osl +++ b/src/storage/thumbnail-access.osl @@ -1,3 +1,8 @@ +export { + loadThumbnailSecret, + thumbnailIsPublic +} + def loadThumbnailSecret(string path) string ( string stored = fs.readFile(path).unwrapOr("").strip() if stored != "" ( From 4a6db4ddb5566b7196428a86f55e004f2ec8ffab Mon Sep 17 00:00:00 2001 From: Mistium <92952823+Mistium@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:21:34 +0000 Subject: [PATCH 5/8] Document the source layout and test on OSL 3.0 in CI The README describes the src/ layout and the check, test, build, route dump and smoke commands. The workflow installs OSL 3.0, type checks, runs the tests, compiles and runs the smoke test, which now fails on any server error. --- .github/workflows/test.yml | 38 ++++++++++++++++++++++++++++++++++++++ README.md | 33 +++++++++++++++++++++++++++++++++ scripts/e2e_smoke.py | 4 ++++ 3 files changed, 75 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2f1d6e2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install OSL + run: | + mkdir -p "$HOME/.local/bin" + curl -fsSL https://git.rotur.dev/osl/.go/raw/branch/main/install.sh -o /tmp/osl-install.sh + OSL_INSTALL_DIR="$HOME/.local/bin" sh /tmp/osl-install.sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: OSL version + run: osl version + + - name: Type check + run: osl lsp check main.osl + + - name: Test + run: osl test + + - name: Compile + run: osl compile main.osl -o "$RUNNER_TEMP/mwapi" + + - name: Smoke test + run: python3 scripts/e2e_smoke.py "$RUNNER_TEMP/mwapi" diff --git a/README.md b/README.md index 3c32d24..b3b30a3 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,39 @@ osl run main.osl The server loads `.env` automatically. Configuration is read in this order: `MISTWARP_` in the real environment, then `.env`, then `` in the real environment. Put per-process overrides in `MISTWARP_` (for example `MISTWARP_PORT`) so a shared shell that exports another project's variables can't replace this API's configuration. +The API needs OSL 3.0. `opal.json` lists the usual commands: + +| Command | Does | +| --- | --- | +| `osl test` | Runs every `*.test.osl` | +| `osl lsp check main.osl` | Type checks the whole program | +| `osl compile main.osl -o main` | Builds the server binary | +| `python3 scripts/route_dump.py .` | Prints every registered route with its middleware | +| `python3 scripts/e2e_smoke.py []` | Runs an offline end to end smoke test, and diffs the responses of two builds | + +## Source layout + +`main.osl` only composes the app: it imports each domain directory, runs startup and migrations, builds the router and starts it. Everything else lives in `src//`: + +| Directory | Owns | +| --- | --- | +| `config` | Reading `.env` and the environment, data paths, size limits, the R2 client | +| `core` | Shared helpers and the community database | +| `http` | Request body helpers, request size limits, auth and admin middleware, cache headers | +| `auth` | Sessions, user identity, editor sessions, the agreement, blocks and mutes, settings, privacy | +| `storage` | Blob storage on R2 or disk, the upload queue, thumbnails, quotas, the Rotur storage ledger, the backpack | +| `projects` | Projects, sharing, remixes, releases, extensions, live collaboration, monetisation | +| `history` | Project history, deltas, history backfill and commit metadata | +| `git` | Git inspection, pull requests and Rotur Git | +| `games` | Game saves, inventories and marketplace products | +| `realtime` | The multiplayer and site WebSockets | +| `community` | Spaces, roadmap, comments, bounties, profiles, news, notifications, milestones, standing, reports, groups | +| `admin` | Admin tools, bans and shadow bans, moderation, error reports, funnel events | +| `classroom` | MistWarp Classroom | +| `rotur` | The Rotur App, plans and perks, payments, signals, badges and webhooks | + +Each directory registers its routes in its own `routes.osl` (`registerProjectRoutes(api)` and so on), and `main.osl` calls them in order under `/v1` and `/api`. Each file lists what other directories may use in an `export {...}` block at the top; anything not exported is private to its directory. Global caches live next to the code that uses them. Tests sit beside the file they test as `.test.osl`. + ## Environment | Variable | Default | Purpose | diff --git a/scripts/e2e_smoke.py b/scripts/e2e_smoke.py index 4964e8a..39f1e56 100644 --- a/scripts/e2e_smoke.py +++ b/scripts/e2e_smoke.py @@ -208,6 +208,7 @@ def main(): args = parser.parse_args() sb3 = open(args.sb3, "rb").read() if args.sb3 else default_sb3() outputs = [] + broken = False for binary in args.binaries: steps = run(binary, sb3, args.keep) text = json.dumps(steps, indent=2, sort_keys=True) + "\n" @@ -220,12 +221,15 @@ def main(): print("%s: %d steps, %d server errors" % (binary, len(steps), len(failures))) for record in steps: print(" %3d %s" % (record["status"], record["step"])) + broken = broken or bool(failures) if len(outputs) == 2: diff = list(difflib.unified_diff(outputs[0].splitlines(), outputs[1].splitlines(), args.binaries[0], args.binaries[1], lineterm="")) if diff: print("\n".join(diff)) sys.exit(1) print("responses match") + if broken: + sys.exit(1) if __name__ == "__main__": From 48543f600a273390c0d7deffc9c38c70fd7252af Mon Sep 17 00:00:00 2001 From: Mistium <92952823+Mistium@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:39:19 +0000 Subject: [PATCH 6/8] Pin OSL in CI, cover agreement and deletion in the smoke test, and make the README an overview CI now builds OSL from a fixed commit instead of whatever main is, so a compiler change can't turn it red or test against a different compiler than production. The smoke test seeds an agreement, so accepting it is exercised instead of returning 400, and it now unshares the project before deleting it, checking both the refusal and the delete. The README's reference material moved to the Backend section of the MistWarp docs. --- .github/workflows/test.yml | 8 +- README.md | 290 ++----------------------------------- scripts/e2e_smoke.py | 7 + 3 files changed, 22 insertions(+), 283 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2f1d6e2..da9a02c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,10 +16,12 @@ jobs: go-version-file: go.mod - name: Install OSL + env: + OSL_COMMIT: 5895982fa61d3e7c9e9c7f819c9e33f645d04f0f run: | - mkdir -p "$HOME/.local/bin" - curl -fsSL https://git.rotur.dev/osl/.go/raw/branch/main/install.sh -o /tmp/osl-install.sh - OSL_INSTALL_DIR="$HOME/.local/bin" sh /tmp/osl-install.sh + mkdir -p "$HOME/.local/bin" /tmp/osl-source + curl -fsSL --retry 3 "https://git.rotur.dev/osl/.go/archive/$OSL_COMMIT.tar.gz" | tar -xz -C /tmp/osl-source --strip-components=1 + (cd /tmp/osl-source && go build -buildvcs=false -o "$HOME/.local/bin/osl" .) echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: OSL version diff --git a/README.md b/README.md index b3b30a3..09a1923 100644 --- a/README.md +++ b/README.md @@ -1,291 +1,21 @@ # mistwarp-api -OSL backend for the MistWarp community platform. It provides Rotur validator auth, project metadata, Cloudflare R2 project storage, and native pull requests between MistWarp forks. Rotur Git can be connected as an optional external Git provider. +The OSL backend for the MistWarp community platform. It signs people in with Rotur, stores projects and their history in Cloudflare R2, and serves everything the community site and editor need: comments, spaces, notifications, MistWarp Classroom, game data and pull requests between MistWarp forks. -## Run +It runs at `https://api.mistwarp.org/v1`. The editor and community site are [scratch-gui](https://github.com/MistWarp/scratch-gui). -Copy `.env.example` to `.env`, fill in the values, then run: +## Getting started -``` -osl run main.osl -``` - -The server loads `.env` automatically. Configuration is read in this order: `MISTWARP_` in the real environment, then `.env`, then `` in the real environment. Put per-process overrides in `MISTWARP_` (for example `MISTWARP_PORT`) so a shared shell that exports another project's variables can't replace this API's configuration. - -The API needs OSL 3.0. `opal.json` lists the usual commands: - -| Command | Does | -| --- | --- | -| `osl test` | Runs every `*.test.osl` | -| `osl lsp check main.osl` | Type checks the whole program | -| `osl compile main.osl -o main` | Builds the server binary | -| `python3 scripts/route_dump.py .` | Prints every registered route with its middleware | -| `python3 scripts/e2e_smoke.py []` | Runs an offline end to end smoke test, and diffs the responses of two builds | - -## Source layout - -`main.osl` only composes the app: it imports each domain directory, runs startup and migrations, builds the router and starts it. Everything else lives in `src//`: - -| Directory | Owns | -| --- | --- | -| `config` | Reading `.env` and the environment, data paths, size limits, the R2 client | -| `core` | Shared helpers and the community database | -| `http` | Request body helpers, request size limits, auth and admin middleware, cache headers | -| `auth` | Sessions, user identity, editor sessions, the agreement, blocks and mutes, settings, privacy | -| `storage` | Blob storage on R2 or disk, the upload queue, thumbnails, quotas, the Rotur storage ledger, the backpack | -| `projects` | Projects, sharing, remixes, releases, extensions, live collaboration, monetisation | -| `history` | Project history, deltas, history backfill and commit metadata | -| `git` | Git inspection, pull requests and Rotur Git | -| `games` | Game saves, inventories and marketplace products | -| `realtime` | The multiplayer and site WebSockets | -| `community` | Spaces, roadmap, comments, bounties, profiles, news, notifications, milestones, standing, reports, groups | -| `admin` | Admin tools, bans and shadow bans, moderation, error reports, funnel events | -| `classroom` | MistWarp Classroom | -| `rotur` | The Rotur App, plans and perks, payments, signals, badges and webhooks | - -Each directory registers its routes in its own `routes.osl` (`registerProjectRoutes(api)` and so on), and `main.osl` calls them in order under `/v1` and `/api`. Each file lists what other directories may use in an `export {...}` block at the top; anything not exported is private to its directory. Global caches live next to the code that uses them. Tests sit beside the file they test as `.test.osl`. - -## Environment - -| Variable | Default | Purpose | -| --- | --- | --- | -| PORT | 5627 | Listen port | -| HISTORY_MIGRATION_WORKERS | 4 | Concurrent background workers used to backfill missing project histories (clamped to 1-8) | -| APP_URL | https://api.mistwarp.org | Public URL of this API | -| ROTUR_APP_KEY | mistwarp | The old validator key, for editors that predate validators keyed to the Rotur App | -| COMMERCE_SERVICE_KEY | | Key registered for `mistwarp` in Rotur's `COMMERCE_SERVICE_KEYS`. Only bounty awards and the groups integration still use it | -| ROTUR_CLIENT_ID | | The MistWarp Rotur App's client ID (`app_1938b6a87799f862`). With the secret, mistwarp-api calls the apps API as MistWarp | -| ROTUR_CLIENT_SECRET | | One of the MistWarp Rotur App's secrets, made with **New secret** on rotur.dev/me/developer | -| MISTWARP_ROTUR_USER | | Rotur account that gets MistWarp's share of game product sales. Without it, the MistWarp Rotur App's owner gets it | -| ROTUR_WEBHOOK_SECRET | | Signing secret (`whsec_…`) of the MistWarp Rotur App's webhook. Without it `POST /v1/rotur/webhook` answers 503 | -| R2_ENDPOINT | | https://accountid.r2.cloudflarestorage.com | -| R2_BUCKET | mistwarp | R2 bucket name | -| R2_ACCESS_KEY_ID | | R2 access key | -| R2_SECRET_ACCESS_KEY | | R2 secret key | -| R2_PUBLIC_BASE | | Public custom domain for the bucket | -| GITEA_URL | https://git.rotur.dev | Optional Rotur Git instance | -| GITEA_ADMIN_TOKEN | | Optional Rotur Git integration token | -| EDITOR_ORIGIN | https://mistwarp.org | Public editor base URL used in links | -| ERROR_REPORT_ORIGINS | (empty) | Comma-separated origins, besides EDITOR_ORIGIN, whose error reports `POST /errors` accepts. Reports from any other origin are ignored | -| ADMIN_USERS | mist | Comma separated admin usernames | -| CLASSROOM_ENABLED | (off) | Set to `true` to open MistWarp Classroom. While it is off, every `/classroom/*` route answers 404 and student accounts cannot sign in, except for MistWarp admins and the students of classes an admin owns, so Classroom can still be tested | -| GITHUB_ORG | MistWarp | GitHub org whose pull requests appear on the roadmap's Changes tab | -| GITHUB_TOKEN | | Optional GitHub token; only raises the search rate limit, the feed works without one | -| REALTIME_URL | wss://api.mistwarp.org/v1/connect | Public multiplayer WebSocket endpoint | -| STRIPE_SECRET_KEY | | Stripe secret key for Classroom plans; online payment is off while any Stripe value is empty | -| STRIPE_WEBHOOK_SECRET | | Signing secret of the Stripe webhook pointed at `/v1/classroom/billing/webhook` | -| STRIPE_CLASSROOM_PRICE | | Recurring Stripe price for one Classroom plan | -| STRIPE_SEAT_PACK_PRICE | | Optional recurring Stripe price for each pack of 10 extra student seats | -| STRIPE_CLASSROOM_PRICE_YEARLY | | Yearly Stripe price for one Classroom plan; when set, checkout offers yearly billing and uses it by default. Online payment needs at least one of the monthly and yearly Classroom prices | -| STRIPE_SEAT_PACK_PRICE_YEARLY | | Optional yearly Stripe price for each pack of 10 extra student seats, used with the yearly Classroom price | - -The multiplayer WebSocket runs inside this API process at `/v1/connect`. It -uses the same listener, domain, and deployment as the HTTP API. - -## Bans - -A MistWarp ban is also a ban from the MistWarp Rotur App (`PUT /v2/apps//bans/`), so Rotur enforces it too: the person's MistWarp tokens stop working and Rotur won't let them sign in to MistWarp. Rotur shows them the ban's reason, and never who made it. Lifting a ban lifts both. - -Every five minutes MistWarp reads the app's bans. Bans made or lifted on rotur.dev/me/developer reach MistWarp's own list, and any ban or unban Rotur couldn't be told about yet is sent again. A ban lifted in MistWarp is never brought back from Rotur while that's pending. Rotur won't ban the app's owner or managers, classroom students, or accounts it doesn't know, so those bans stay MistWarp-only. Bans whose reason only names a report are sent with a general reason instead. - -## Plans - -Perks follow the person's Rotur plan. mistwarp-api asks the apps API (`GET /v2/apps//users//plan`), which answers whatever their privacy settings. Rotur's public profile hides the plan from anyone who can't see the balance, which covers every account without a date of birth, so it's only read for people the apps API doesn't know. Answers are kept for 5 minutes, and during an outage the last known plan stays. - -## Safety signals - -The MistWarp Rotur App declares that people can talk and can spend credits. Before a comment is posted, mistwarp-api asks Rotur's message signal about the owner of the project or profile and the author of the comment it replies to. Comments are public, like replies to Rotur posts, so only a block or a ban (`blocked`, `banned`) stops one. When someone's message settings don't take messages from the author (`settings`, `unavailable`), the comment is still posted but doesn't notify them, and the same goes for replies, mentions and other notifications that carry someone's words. Before a purchase or donation starts, it asks the purchase signal. A "no" is shown to the person in Rotur's own words. - -Rotur only answers about people who have used MistWarp through Sign in with Rotur, or made their account on MistWarp. For anyone else, or if Rotur can't be reached, MistWarp carries on as it did before. Rotur counts credits towards a parent's monthly limit when it moves them, so the purchase signal only asks. - -## Payments - -Buying a game product, donating with a comment and refunding a game product are Rotur payment requests (`POST /v2/apps//payment-requests`). mistwarp-api never holds a permission to spend anyone's credits. - -1. The editor asks for an intent. mistwarp-api asks Rotur for the payment, with a purchase key (`mwbuy_`, `mwgame_`, `mwdonate_` or `mwrefund_`) as its `reference`, and answers with the key and Rotur's `approveUrl`. -2. The person approves it on rotur.dev with their password. Rotur moves the credits, shared between everyone the sale pays: the creator, collaborators, the remixed project's creator and MistWarp's fee. -3. Rotur's `payment.completed` webhook marks the key paid and delivers game products. The editor's confirm call does the same if it gets there first, asking Rotur whether the request was paid. Whichever comes second finds it done. - -Donations are delivered when their comment is posted, and refunds when the product is revoked. Paid keys waiting for that are kept for 30 days, and unpaid ones for an hour (Rotur's requests expire after 15 minutes). - -### Project sales have ended - -Projects can no longer be sold. `POST /v1/projects/:id/purchase/intent` and `/purchase/confirm` answer 410 with the code `project_sales_ended`, and a `PUT /v1/projects/:id` that sets a price above 0 answers 400 with the same code. A project that had a price plays, and can be remixed and seen inside, the same as a free one with the same settings. Remixes of paid projects are no longer kept private. The webhook still records a `mwbuy_` payment that was started before sales ended, so it can be refunded. - -What was sold stays readable: buyers keep their records in `GET /v1/me/purchases`, and creators keep the buyers, revenue and sales history in their project analytics and `GET /v1/me/stats`. - -`scripts/refund_project_sales.py` pays every buyer back the full price of every project they bought, from one Rotur account, with one transfer per buyer (`POST /v2/me/transfers`). It sends nothing without `--apply`. Each transfer is written to `data/project-sale-refunds.json` before it is sent and marked when Rotur answers, so running it again never pays a purchase twice. It stops if a project purchase can still be paid on Rotur, if the token doesn't belong to `--payer`, if the payer doesn't have enough credits, or if an earlier transfer's outcome is unknown (check the payer's Rotur transactions, then `--resolve =sent` or `=failed`). +You need OSL 3.0. ```sh -python3 scripts/refund_project_sales.py -ROTUR_REFUND_TOKEN= python3 scripts/refund_project_sales.py --apply --payer -``` - -Tests: `python3 -m unittest scripts/test_refund_project_sales.py`, and with a compiled binary `MWAPI_BIN= python3 -m unittest scripts/test_project_sales_api.py`. - -## Error reports - -The editor and community site send uncaught errors to `POST /v1/errors`, from `EDITOR_ORIGIN` or an origin in `ERROR_REPORT_ORIGINS`. A report holds the error message and stack, the kind of page (its address with IDs, usernames, query and fragment removed), the browser name, major version and operating system, the viewport, the app version and the time. It never holds the username, the project ID, the full user agent or the IP address, and resolving a report does not record who resolved it. Email addresses, URL queries, avatar usernames and usernames, class, student or project IDs in API and file paths are removed from the message and stack too. Admins read them through `GET /v1/admin/errors`. - -Reports are kept for 7 days, resolved or not, and only the newest 1,000 are kept at all. Older ones are deleted when a new report comes in, when the server starts and every five minutes. Reports stored before this change are scrubbed the same way by the next sweep. - -## Rotur webhooks - -Set the MistWarp Rotur App's webhook to `https://api.mistwarp.org/v1/rotur/webhook` on rotur.dev/me/developer, with privacy requests on, and put its signing secret in `ROTUR_WEBHOOK_SECRET`. - -- `user.deleted`, `user.left` and `user.banned` erase everything MistWarp holds about that person, the same as deleting their data from Settings. -- A `privacy.request` for `erasure` does the same. One for `access` sends them a MistWarp notification pointing to the download in Settings. -- `payment.completed` finishes the purchase it names (see Payments). - -Deliveries are checked against `Rotur-Signature` and refused if their timestamp is more than five minutes out. Each is saved to `data/rotur-webhooks.json`, answered, then handled in the background, and a retried delivery is only handled once. Anything not yet handled when the server stops is handled when it starts. - -Rotur only sends these for people who have used MistWarp through Sign in with Rotur, or whose account was made on MistWarp. The five-minute check of `/accounts/deleted_check` stays for everyone else. - -## Reports - -Every report is also filed in the MistWarp Rotur App's report queue (`POST /v2/apps//reports`), naming the reporter when Rotur knows them as a MistWarp user. Reports use Rotur's categories. One in a priority category (`csea`, `threat_to_life`, `self_harm`, `terrorism`) goes to Rotur's safety team as soon as it's filed. MistWarp's own queue and actions are unchanged. Dismissing a report closes it on Rotur as dismissed, and any other action closes it as resolved, except reports Rotur is still reviewing. - -Who a report is about, and the snapshot sent with it, are worked out by mistwarp-api from the content when the report is made, never taken from the reporter: a project's owner and its title, description, instructions and notes; a comment's author and its text; a profile's owner and bio. Moderation actions on a report (ban, warn) use the same person. A report whose subject can't be found stays MistWarp-only, because Rotur needs someone to name. - -A report Rotur couldn't be reached for stays pending and is filed by the five-minute reconcile loop. One closed in MistWarp before Rotur had it is closed on Rotur as soon as it's filed. - -## Badges - -MistWarp gives badges on people's Rotur profiles as the MistWarp Rotur App. Which badges exist, and what earns them, is data. Define the badges on rotur.dev/me/developer, then map MistWarp's events to them in `data/rotur-badges.json`: - -```json -{"events": { - "project_shared": [{"badge": "creator", "delta": 1}], - "love_received": [{"badge": "loved", "delta": 1}], - "remixed": [{"badge": "remixed", "delta": 1}], - "comment_posted": [{"badge": "commenter", "delta": 1}], - "streak": [{"badge": "streak", "progress": "value"}] -}} +cp .env.example .env +osl run main.osl +osl test ``` -| Event | When | -| --- | --- | -| `project_shared` | Someone shares a project for the first time | -| `love_received` | Someone else loves their project, counted once per person per project | -| `remixed` | Someone else shares a remix of their project | -| `comment_posted` | They post a comment anywhere | -| `streak` | They save a project on a new day. The value is how many days in a row they have done so | - -`delta` adds to their progress. `"progress": "value"` sets it to the event's value. A rule with neither is skipped. Updates are sent in the background, and Rotur only gives badges to people who have used MistWarp through Sign in with Rotur. Without the file, nothing is sent. The file is read on every event, so changing it needs no restart. - -## Development feed - -`/v1/development/pulls` lists MistWarp's own open and recently merged pull -requests, and `/v1/roadmap` resolves the ones linked to each entry. - -GitHub's search quota is 10 requests a minute for an unauthenticated caller and -is counted per IP, so the feed is built to stay well inside it: - -- One search covers the whole org, so a refresh costs two requests rather than - one per repository. -- A refresh happens at most once every five minutes. The snapshot is stored, so - a restart reuses it and costs nothing — a crash loop cannot burn the budget. -- Every response's `X-RateLimit-*` headers are recorded, and a refresh is - refused when the remaining quota would not cover it, keeping a small reserve - for anything else on the same address. -- A 403 or 429 backs off until the window reopens, honouring `Retry-After`. - -Search responses carry no `ETag` and are sent `no-cache`, so a conditional -request cannot earn a free 304; not spending the request is the only lever. - -`GITHUB_TOKEN` raises the ceiling but is not required. - -## Deployment layout - -The site and editor are one scratch-gui build (community pages live in -`scratch-gui/src/community`), served on the frontend domain: - -| Path | Serves | -| --- | --- | -| / | community app (webpack `community` entry -> index.html) | -| /editor | scratch-gui editor | -| /embed.html | scratch-gui embed player (project pages iframe this) | -| /project/*, /explore, /users/*, /settings | community app (client routing) | - -This API runs at `api.mistwarp.org`, with `mwapi.mistium.com` retained as a -backwards-compatible hostname. The frontend calls it at -`https://api.mistwarp.org/v1` directly. In dev, leave the API base unset and -webpack-dev-server proxies `/v1` to `http://localhost:5627`. Auth is -Bearer-token based (the session token returned by `/v1/auth`), so a -cross-domain API works without shared cookies. CORS echoes the request Origin -with credentials, so any frontend origin is accepted. - -`/v1` is the canonical route group. Every route is also registered under -`/api` for backwards compatibility, including uploads and their larger body -limits. New clients and generated URLs must use `/v1`. - -R2 bucket only needs public GET through `R2_PUBLIC_BASE`, and the public domain must expose the bucket's `assets/` prefix and allow CORS GET requests from the MistWarp frontend. Project metadata keeps the API `/blobs/assets` base. That endpoint serves a local asset when its file exists under `data/blobs/assets/`; otherwise it redirects to `R2_PUBLIC_BASE`. All writes go through this server. With no R2 configured, the server falls back to local disk (`data/blobs/`, served at `/blobs`) so it runs locally with zero setup. - -## Upload pipeline - -The editor POSTs a sparse sb3 to `POST /v1/projects/:id/upload` (multipart, fields `project` and optional `thumbnail`). The server extracts at most 256 MiB of `project.json`, validates it incrementally, requires asset filenames to match their content, and limits every asset to 10 MiB and all assets to 50 MiB. The JSON is accepted only when its gzip representation is at most 20 MiB. - -### Commit inspection - -The read-only commit endpoints inspect the stored `.mwp` on the server. Clients do not need to download the complete workspace to render history or browse an old revision. - -- `GET /v1/projects/:id/commits/:sha` returns commit metadata, its first parent, and changed-file records. Each record has `path`, `status`, `oldOid`, `newOid`, `oldSize`, and `newSize`. With `?inline=1`, small text changes also include `oldData` and `newData`, encoded as base64 and capped at 1 MiB per file and 8 MiB per response. Root commits report every file as added. `legacy: true` means the change includes an old `project.sb3`; clients that need the expanded Fractch diff should use their legacy conversion fallback. -- `GET /v1/projects/:id/commits/:sha/tree` returns `commit` and a flat `files` array. Each file has `path`, `oid`, `mode`, `size`, and `binary`. -- `GET /v1/projects/:id/commits/:sha/file?path=` returns one file record and `content`, encoded as base64 by JSON. -- `GET /v1/projects/:id/commits/:sha/co-authors` returns the Rotur users credited on that commit. The older `/collaborators` path remains an alias. -- `PATCH /v1/projects/:id/commits/:sha` accepts `{coAuthors: string[]}` with at most 25 existing Rotur usernames. `{collaborators: string[]}` remains an input alias. It replaces the commit's co-author metadata without rewriting Git objects or changing any SHA. Responses expose canonical `coAuthors` records as `{username, userId}` and a compatibility `collaborators` alias. Only the project owner, a maintainer, or an administrator may patch commit credits. - -These routes use the same see-inside policy as workspace downloads. The server caches materialized workspace layers and computed JSON by the ordered content-addressed layer keys. Public projects may be cached by the CDN for one hour; private and unlisted responses use `private, no-store`. Stable ETags let browsers revalidate without reparsing Git objects. The local inspection cache keeps at most 256 files or 2 GiB and removes entries older than 24 hours. - -Before extraction, the API rejects archives with unsafe paths, duplicate entries, symlinks, unsupported compression methods, or more entries and expanded bytes than the endpoint allows. MistWarp history archives are capped at 128 MiB compressed and 20,000 entries. Their expanded-byte ceiling matches the account's maximum project size, including its tier-specific asset allowance. The API reads each history entry through that ceiling before storing it, so forged ZIP metadata cannot bypass the limit. - -- `assets/`: content addressed, shared across all projects and remixes, uploaded once ever -- `projects//project.json`: the gzip-encoded playable snapshot -- `projects//thumb.png` - -Project JSON, history archives, and assets are staged on local disk before the upload request returns. The API serves those files locally until the project has been inactive for seven days. Every edit restarts that window, including saves with unchanged content. Shared files wait until every project referencing them has been inactive for seven days. The background loop checks every five minutes, uploads eligible files to R2, and removes each local copy only after a successful upload. Failed uploads stay local for retry. Existing local copies recorded in the R2 storage index are also removed after inactivity without uploading them again. With R2 disabled, files stay local. Git carries every save. `data/assets-index.json` tracks known assets so duplicates are never re-uploaded. - -The first editor save uploads a compact MWP archive containing the Git repository without a duplicate worktree. Later saves compare the local HEAD with the server HEAD and send only new Git objects plus updated refs. The API stores those archives as content-addressed layers, so remixes share their parent's history instead of copying it. It compacts a chain after eight layers. If the user saves without making a commit, the editor sends a full archive with the worktree so uncommitted changes are not lost. - -For a delta whose `baseHead` still matches the stored head, the API removes loose Git objects already present in the materialized base before storing the new layer. It retains the delta manifest and refs, verifies the combined archive, and stitches only the new commits and graph nodes ahead of `baseHead` onto the inherited metadata. A final locked head comparison rejects concurrent history changes before project metadata is saved. - -## Storage limits - -Each plan has a total storage limit: 500 MB on Free, 2 GB on Lite, 10 GB on Plus and 50 GB on Pro. A user's usage is the sum of every project they own, trashed projects included. Each project counts its assets, its compressed project JSON and its history archive, even when an asset is shared with other projects. Saving is checked against the owner's limit before anything is stored. A save may make a project bigger only while it fits in the free space, so a user over the limit can still save a project at its current size or smaller. Assets uploaded ahead of a save count as pending until a saved project references them, or for 7 days. Deleting a project frees its space once it is removed from the trash. Backpack items count toward the same limit, and deleting one frees its space straight away. `GET /v1/me/quota` returns `used`, `pending`, `limit`, `remaining`, `backpack` (the bytes held in the backpack) and the five largest projects. On first start after this change, the server measures the history size of existing projects from local copies and the R2 inventory. - -## Backpack - -The editor backpack syncs to the signed-in account. Items are scripts, sprites, costumes and sounds, stored as a body file plus an optional PNG or JPEG thumbnail under `backpack//` in R2 (or `data/blobs/` locally), with the per-user list in `data/backpack/`. Blob URLs are unguessable and served sandboxed with `nosniff`; only the owner can list items. - -| Route | Purpose | -| --- | --- | -| `GET /v1/me/backpack?type=&offset=&limit=` | Newest first, up to 100 per page, with `total`, `count` and `bytes` | -| `POST /v1/me/backpack` | Multipart `type`, `mime`, `name`, `body`, and optional `thumbnail` with `thumbnailMime`; checked against the storage limit | -| `PATCH /v1/me/backpack/:id` | Rename with `{name}` | -| `DELETE /v1/me/backpack/:id` | Delete one item | -| `POST /v1/me/backpack/delete` | Delete several with `{ids}`; returns `deleted` and `freedBytes` | - -A body may be up to 20 MB and a backpack holds up to 2000 items. Account deletion, deleted Rotur accounts and data export all include the backpack. - -## Auth flow - -1. Client holds a Rotur token from Sign in with Rotur, with the `validators:generate` scope. -2. Client calls `POST https://api.rotur.dev/v2/validators` with `{"key": ""}` and the token in the `Authorization` header, never the URL. MistWarp's own token can always make validators for its app, without `validators:generate`. Older editors send `ROTUR_APP_KEY` instead. It must be the same Rotur instance the server validates against. -3. Client calls `POST /v1/auth?v=`; the API validates it against `https://api.rotur.dev/validate`, with the app ID first and then the old key, and returns a 7 day session token (also set as the auth_token cookie). Bearer header and cookie are both accepted. For the app ID, Rotur also refuses anyone the app has banned, or who otherwise can't use MistWarp, with a message for them. - -Only MistWarp's own sign-in token can make app-ID validators (rotur/api#85), so the desktop app, which still uses the old sign-in, and older tokens send the old key. Rotur doesn't check the old key against MistWarp's Rotur App, so for those sign-ins Rotur's suspension, adults-only, parental approval and age settings aren't enforced, and a rotur.dev ban only applies once the five-minute ban sync has brought it in. Old-key validators made by another Rotur App's sign-in are refused. The gap closes once the desktop app uses Sign in with Rotur through a loopback redirect, which needs Rotur to accept any port on `http://127.0.0.1`. - -A refusal from Rotur is answered with 403 and its `code`, `error`, and for an app ban its `reason` and `until`. - - -## Milestone notifications - -MistWarp sends one-time notifications at 5, 25, 50, 100, 500, and 1,000 likes or followers. Likes are counted separately for each project, comment, roadmap post, space, news post, profile post, and theme. The `like_milestones` collection stores progress independently of the inbox, so clearing notifications, unliking, and re-liking do not reset it. - -Project and community reactions check milestones when saved. Profile-post, theme, and follower checks read existing public APIs and keep their state in MistWarp. The client requests a check after a like or follow; opening the inbox also checks the current account's followers. These sampled counts can detect a threshold crossed between visits, but cannot detect a count that rose and fell entirely between checks. Existing counts establish a baseline without sending every earlier milestone. No Rotur or WarpTheme server changes are required. - -## Project source access +The code is split by domain under `src/`, and `main.osl` composes them. -Projects with See inside disabled expose commits, branches, files, and workspace archives only to their owner. Pull-request inspection follows the same restriction. The project response exposes `canViewSource` for clients. +## Documentation -Deploy the API and client changes together. Purge previously cached project history and workspace responses from the CDN when deploying this policy; new responses use `private, no-store`. This policy controls MistWarp's inspection endpoints. Playback still sends the data required to run a Scratch project to the browser. +The [Backend section of the MistWarp docs](https://mistwarp.org/docs/backend/overview) covers the source layout, configuration, sign-in, safety and moderation, payments, storage, and community features. diff --git a/scripts/e2e_smoke.py b/scripts/e2e_smoke.py index 39f1e56..c376c7a 100644 --- a/scripts/e2e_smoke.py +++ b/scripts/e2e_smoke.py @@ -57,6 +57,10 @@ def __init__(self, binary, keep): os.makedirs(os.path.join(data, "sessions")) with open(os.path.join(self.dir, ".env"), "w") as env: env.write("PORT=%d\nAPP_URL=http://127.0.0.1:%d\nROTUR_BASE=http://127.0.0.1:9\n" % (self.port, self.port)) + with open(os.path.join(data, "agreement.json"), "w") as handle: + json.dump({"version": 1, "text": "", "updatedAt": 1700000000000}, handle) + with open(os.path.join(data, "agreement.md"), "w") as handle: + handle.write("Smoke test agreement") with open(os.path.join(data, "userids.json"), "w") as handle: json.dump({"users": {uid: name for name, uid in USERS.items()}}, handle) for name, uid in USERS.items(): @@ -187,7 +191,10 @@ def step(name, method, path, who=None, body=None, upload=None, headers=()): step("delete remix", "DELETE", "/v1/projects/", "alice") step("trash", "GET", "/v1/me/trash", "alice") step("purge remix", "DELETE", "/v1/trash/", "alice") + step("delete shared project refused", "DELETE", "/v1/projects/", "sam") + step("unshare", "POST", "/v1/projects//unpublish", "sam", {}) step("delete project", "DELETE", "/v1/projects/", "sam") + step("trash sam", "GET", "/v1/me/trash", "sam") step("missing project", "GET", "/v1/projects/nope") step("wrong method", "PATCH", "/v1/projects/", "sam", {}, headers=("Allow",)) step("legacy prefix", "GET", "/api/agreement") From 4065dafde0de3a0c11633a35fc358edbf0f27ec2 Mon Sep 17 00:00:00 2001 From: Mistium <92952823+Mistium@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:56:58 +0000 Subject: [PATCH 7/8] Add AGENTS.md and a conventions check scripts/check_conventions.py rejects code comments and em dashes in added lines of OSL and Python files, with a scanner that ignores // inside strings. It runs in CI on pull requests and as opal's conventions script. AGENTS.md covers the src/ layout, the /v1 contract and the tools that guard it, the OSL 3.0 traps that changed behaviour in this migration, and the checks to run. --- .github/workflows/test.yml | 6 ++ AGENTS.md | 33 ++++++++++ opal.json | 41 +++++++++++-- scripts/check_conventions.py | 114 +++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 AGENTS.md create mode 100644 scripts/check_conventions.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da9a02c..f81a78d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Conventions + if: github.event_name == 'pull_request' + run: python3 scripts/check_conventions.py --base=origin/${{ github.base_ref }} - uses: actions/setup-go@v5 with: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..78f88d3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# AGENTS.md + +mistwarp-api is the OSL backend behind https://api.mistwarp.org/v1, for the MistWarp community site and editor (scratch-gui). It needs OSL 3.0. Community data is in SQLite (`data/community.db`), other state is JSON under `data/`, and project files are in Cloudflare R2. + +## Where code goes + +- `main.osl` only composes the app: it imports every domain, runs startup and migrations, registers routes and starts the server. Don't put features in it. +- `src//` holds everything else (config, core, http, auth, storage, projects, history, git, games, realtime, community, admin, classroom, rotur). The [Backend Overview](https://mistwarp.org/docs/backend/overview/) describes what each owns. +- Each file starts with an `export {...}` block listing what other directories may use; anything not listed is private. Keep exports minimal. +- A domain registers its routes in its own `routes.osl` (`registerXRoutes(api)`). Global caches live next to the code that uses them. Tests sit beside their source as `.test.osl`. + +## The /v1 contract + +The editor and site depend on every path, method, middleware order, body limit and response shape. Don't change them unless the task is to. `python3 scripts/route_dump.py .` prints every route with its middleware, so diff its output before and after any routing change. `python3 scripts/e2e_smoke.py ` runs an offline end-to-end pass against two builds and diffs their responses. + +## OSL traps + +- String `.len` counts characters. Use `.toBytes().len` for anything measured in bytes: stored sizes, quotas, body caps, R2 objects. +- Arrays are shared on assignment and when passed to a function. `.assert(array)`, `?? []` and function returns copy, so after changing a copied array, write it back (`obj.field = list`). +- `result`, `set`, `error`, `option`, `xml`, `thread`, `unknown` and `channel` are reserved type names, so don't use them as variable names. +- The runtime throws where older versions returned defaults. Read request values as `(c.query("x") ?? "")`, parse bodies with `requestJSON(c)`, discard unused calls with `void`, and end fallible reads with `.unwrapOr()`. + +## Checks + +`osl test`, `osl lsp check main.osl`, `osl compile main.osl -o main`, and `python3 scripts/check_conventions.py`. The last one rejects code comments and em dashes in the lines you add. CI (`.github/workflows/test.yml`) runs all of them plus the smoke test, with OSL pinned to a commit. + +## Working here + +- The default branch is `main`. Open PRs with `gh pr create -R MistWarp/api --base main`. +- Never deploy, restart or SSH to the production server unless explicitly asked. +- New behaviour comes with tests. A refactor keeps the route dump and the smoke diff identical. +- Never add `Co-Authored-By` or any AI or tool attribution to commits or PRs. +- READMEs are overviews. Documentation goes in the Backend section of MistWarp/docs (https://mistwarp.org/docs/backend/overview/). diff --git a/opal.json b/opal.json index bee0a96..4f9c266 100644 --- a/opal.json +++ b/opal.json @@ -3,11 +3,40 @@ "version": "1.0.0", "main": "main.osl", "scripts": { - "dev": ["osl", "run", "main.osl"], - "test": ["osl", "test"], - "check": ["osl", "lsp", "check", "main.osl"], - "build": ["osl", "compile", "main.osl", "-o", "main"], - "routes": ["python3", "scripts/route_dump.py", "."], - "smoke": ["python3", "scripts/e2e_smoke.py"] + "dev": [ + "osl", + "run", + "main.osl" + ], + "test": [ + "osl", + "test" + ], + "check": [ + "osl", + "lsp", + "check", + "main.osl" + ], + "build": [ + "osl", + "compile", + "main.osl", + "-o", + "main" + ], + "routes": [ + "python3", + "scripts/route_dump.py", + "." + ], + "smoke": [ + "python3", + "scripts/e2e_smoke.py" + ], + "conventions": [ + "python3", + "scripts/check_conventions.py" + ] } } diff --git a/scripts/check_conventions.py b/scripts/check_conventions.py new file mode 100644 index 0000000..42702b3 --- /dev/null +++ b/scripts/check_conventions.py @@ -0,0 +1,114 @@ +import argparse +import re +import subprocess +import sys + +EM_DASH = "\u2014" +TEXT_FILE = re.compile(r"\.(osl|go|py|md|json|yml|yaml|sh|txt)$") +SKIPPED = [re.compile(r"(^|/)go\.sum$"), re.compile(r"^data/")] +DIRECTIVE = re.compile(r"^(//|#)\s*(nolint|noqa|go:|lint:|type:\s*ignore)") + + +def git(*args): + return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout + + +def parse_diff(text): + files, removed = {}, set() + current, line = None, 0 + for row in text.split("\n"): + if row.startswith("+++ "): + path = row[4:] + current = None if path == "/dev/null" else re.sub(r"^b/", "", path) + if current is not None: + files.setdefault(current, {}) + elif row.startswith("@@"): + match = re.search(r"\+(\d+)", row) + line = int(match.group(1)) if match else 0 + elif row.startswith("+") and current is not None: + files[current][line] = row[1:] + line += 1 + elif row.startswith("-") and not row.startswith("---"): + removed.add(row[1:].strip()) + return files, removed + + +def osl_comment_lines(text): + lines, line, index, quote = set(), 1, 0, None + while index < len(text): + char = text[index] + if quote: + if char == "\\": + index += 1 + elif char == quote: + quote = None + elif char == "\n": + line += 1 + elif char in "\"'`": + quote = char + elif text.startswith("//", index): + end = text.find("\n", index) + end = len(text) if end == -1 else end + if not DIRECTIVE.match(text[index:end]): + lines.add(line) + index = end + continue + elif text.startswith("/*", index): + end = text.find("*/", index + 2) + end = len(text) if end == -1 else end + 2 + span = text[index:end] + for offset in range(span.count("\n") + 1): + lines.add(line + offset) + line += span.count("\n") + index = end + continue + elif char == "\n": + line += 1 + index += 1 + return lines + + +def python_comment_lines(text): + found = set() + for number, row in enumerate(text.split("\n"), start=1): + stripped = row.strip() + if stripped.startswith("#") and not stripped.startswith("#!") and not DIRECTIVE.match(stripped): + found.add(number) + return found + + +def main(): + parser = argparse.ArgumentParser(description="Reject code comments and em dashes in added lines") + parser.add_argument("--base", default="origin/main") + parser.add_argument("--staged", action="store_true") + options = parser.parse_args() + diff_args = ["diff", "--cached"] if options.staged else ["diff", git("merge-base", options.base, "HEAD").strip()] + files, removed = parse_diff(git(*diff_args, "--unified=0", "--no-color", "--diff-filter=ACMR")) + problems = [] + for path, added in files.items(): + if not TEXT_FILE.search(path) or any(pattern.search(path) for pattern in SKIPPED): + continue + for line, content in added.items(): + if EM_DASH in content: + problems.append(f'{path}:{line} em dash: use a comma, parentheses or "to"') + if path.endswith(".osl"): + scan = osl_comment_lines + elif path.endswith(".py"): + scan = python_comment_lines + else: + continue + text = git("show", f":{path}") if options.staged else open(path, encoding="utf-8").read() + for line in sorted(scan(text)): + content = added.get(line) + if content is not None and content.strip() and content.strip() not in removed: + problems.append(f"{path}:{line} code comment: name things so the code explains itself") + if problems: + print("\n".join(problems), file=sys.stderr) + print(f"\n{len(problems)} convention problem{'' if len(problems) == 1 else 's'} in the changed lines.", file=sys.stderr) + return 1 + print("conventions ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 0cfb013b64735d7c88e1ada4e0973cd3a191346c Mon Sep 17 00:00:00 2001 From: Mistium <92952823+Mistium@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:16:21 +0000 Subject: [PATCH 8/8] Install OSL by cloning, read comments with real tokenizers, and tidy opal.json git.rotur.dev answers 500 for archive downloads, so CI clones OSL and checks out the pinned commit instead. The conventions check uses Python's tokenize for Python files, so trailing comments are caught and # inside strings is not, and it scans Go files too. Like the JavaScript check, it compares comment text against removed lines, so editing a line with an existing comment does not flag it. opal.json keeps its original formatting. --- .github/workflows/test.yml | 5 +- AGENTS.md | 2 +- opal.json | 42 ++---------- .../check_conventions.cpython-314.pyc | Bin 0 -> 8192 bytes scripts/check_conventions.py | 62 ++++++++++-------- 5 files changed, 46 insertions(+), 65 deletions(-) create mode 100644 scripts/__pycache__/check_conventions.cpython-314.pyc diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f81a78d..b22f980 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,8 +25,9 @@ jobs: env: OSL_COMMIT: 5895982fa61d3e7c9e9c7f819c9e33f645d04f0f run: | - mkdir -p "$HOME/.local/bin" /tmp/osl-source - curl -fsSL --retry 3 "https://git.rotur.dev/osl/.go/archive/$OSL_COMMIT.tar.gz" | tar -xz -C /tmp/osl-source --strip-components=1 + mkdir -p "$HOME/.local/bin" + git clone --filter=blob:none https://git.rotur.dev/osl/.go /tmp/osl-source + git -C /tmp/osl-source checkout "$OSL_COMMIT" (cd /tmp/osl-source && go build -buildvcs=false -o "$HOME/.local/bin/osl" .) echo "$HOME/.local/bin" >> "$GITHUB_PATH" diff --git a/AGENTS.md b/AGENTS.md index 78f88d3..96eeae5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ mistwarp-api is the OSL backend behind https://api.mistwarp.org/v1, for the Mist - `main.osl` only composes the app: it imports every domain, runs startup and migrations, registers routes and starts the server. Don't put features in it. - `src//` holds everything else (config, core, http, auth, storage, projects, history, git, games, realtime, community, admin, classroom, rotur). The [Backend Overview](https://mistwarp.org/docs/backend/overview/) describes what each owns. -- Each file starts with an `export {...}` block listing what other directories may use; anything not listed is private. Keep exports minimal. +- Each file has an `export {...}` block after its imports, listing what other directories may use; anything not listed is private. Keep exports minimal. - A domain registers its routes in its own `routes.osl` (`registerXRoutes(api)`). Global caches live next to the code that uses them. Tests sit beside their source as `.test.osl`. ## The /v1 contract diff --git a/opal.json b/opal.json index 4f9c266..3da095f 100644 --- a/opal.json +++ b/opal.json @@ -3,40 +3,12 @@ "version": "1.0.0", "main": "main.osl", "scripts": { - "dev": [ - "osl", - "run", - "main.osl" - ], - "test": [ - "osl", - "test" - ], - "check": [ - "osl", - "lsp", - "check", - "main.osl" - ], - "build": [ - "osl", - "compile", - "main.osl", - "-o", - "main" - ], - "routes": [ - "python3", - "scripts/route_dump.py", - "." - ], - "smoke": [ - "python3", - "scripts/e2e_smoke.py" - ], - "conventions": [ - "python3", - "scripts/check_conventions.py" - ] + "dev": ["osl", "run", "main.osl"], + "test": ["osl", "test"], + "check": ["osl", "lsp", "check", "main.osl"], + "build": ["osl", "compile", "main.osl", "-o", "main"], + "routes": ["python3", "scripts/route_dump.py", "."], + "smoke": ["python3", "scripts/e2e_smoke.py"], + "conventions": ["python3", "scripts/check_conventions.py"] } } diff --git a/scripts/__pycache__/check_conventions.cpython-314.pyc b/scripts/__pycache__/check_conventions.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2aa9485ed1eae698f5345767401c51d5951f607e GIT binary patch literal 8192 zcma)BYfu|kmcA{u9_S6?;Rg>1FtLm!*#QTeI3&c#0b@XJh_DkOB1;X(=#}nP3^F^> z%xu+wV;4Aa%1TW#(bQyiaW-2xyV;7Xc7Aww_J^moYDXZ9lnzys+O6HH)KpD5ake(u zKfC9))Z!6v=0e}@zJ2b!=bn4cch5O$)@fA;%AZpIb?J|#2>l0s5sSR=@aQ;!Py%@n zMf4ype`EaM zDQpt1WLGM=NX4lrMHS-IloDzURRXn^QbDbw)FWC-1DAG0XP0t%N(Zfmky1*3{5(Sb zeF#;f5z^V1=XrB&Mk59@YArO0Gu45t8idPY_!58Xk4d5a_r%S3liPMM!Uku?BQw$I znUH_x5*rE6OoxIq(_X09i5YH+voyxF2R$=(%Xq|XV`HJlxZLCSa$bA{-d*SFOtGT1F}eY>3Dz}g1gK&LHj0oozENPVhlYRiE+^w z$CDflYm@RSHg++}M0_;M@=_)i=H)Es2O0~(lU`<=WwCM`;;UI76NqxGT^McD7YV;c zhq*u`%-W*U4CX6ZPLu9O zK!oMW2QjunNu$0#FX0#MTpyOKn~)x1EHwQC3DKQ zk-;k^5h0wYZu|(xAIYkPp2lG|KKJ-<&MRP<>rAW7NhrN7@$0zP)+TJb%uJMhSxIMRsYZrp(*rAsEx z5mmSp#Wn92dh116m%<6qv%PqVQWaD8tE^Cp1b!z*0LKP>Qo10DTU&5rQzdv4L_tUT0!?9p6 zF7;fr^C&KP?zyPUtrj&eWoeF=vC&|F14HJ#498v$ za1*?Qq2XKXA~e#RpC0qZf*cq&?PYuuycD2`myI)#Sd^D~{eBpL1KYMJ84O3fY%CC@ zSzf`=p~!2rpI7)|3RaCue{CUM}TdsUh(mXqq(;MgGbMa(jR$n(O&8c$94JU+qT*)5B_{DV^;| zlY1Gg;5%cW4)7y^Y{(-V?=9qIP!y(&X?00AP|<}%EjtcEb`btato6fN5lrSHi&pql z!TKZuqJf43;*pf}_+>a+VejBgy3OjO4x;ba@Q^?cwgd=BL`i6C23yGEBDgKkPqM9_ z1ZegGjqC)h&@c2Hk_kO^yI{B6B^5EM$nUel*l0&ABsb^tM$>B}2iYxuII9IylTYE?4 z?h&UX~RtIB8ew9bLmoUmPxOvfMSBQoSU#&97-L_ zYAq{TORlW)mgR;e*_AqwDLXiODpyyZy7=xip7*9@(FYBh()qJn6)$y^hAz867kS(_?mqYJNx&4904#xmyh8LJw z7`o;zz%aBcwF{-!Rd2|0YD41e_aPO)C+}9S3(~itD6#(s9d0*1mxbaix_e>^LS_I4 z(WrKE0>y7&aM!^Jm4G`TM0Xn_;HOPaKfu=Zg5hjja5{S%1&3sZ_|1bhJ5C-#fIy(e zfb2AE9}$9!9h{mCr@07a`B|~K5<9s#F!m4fvh(~~;AB1zC)l)2n`MCwb6aum5gS@0 za^!~{sYU?OL`+fx;Z6WOEv-QT!Xja?BORACm@Qgf$wemVa3D_e`f)le*!-x_#PX6r zgjWuN+Y=t|8Q_%+?ez=Z4T&Q*UU6!mug}pxggqlLaU2q1N&!w78%)5fhVal1hKVq| zdT=_-d8dRIi&Vf7<|rnT;Q$Z#6Iz5o~d5mewGX>C3U>^zQj=jdi)kx~8|@iG6zI z<16=${=<*%m#(&*xgY!e%_BjJAShZK{r==TlZ)o}!i)4$@OCg= z^YohGgDTfaestO91es(C1dr~hT)AVGiG zX*{Jy|5W`H)c;)dB-FoE*PLpW{`L?7b)2+8cpaD8#v@Qgr+v_)Sg_lOMf0c?Hq)#B zb0T1hXafVrC5q2wY}VTfC84Ag*;b6EcAzC?NmSx4Mq?YJ+Q76S51{EMC@oyNe&U$i zjL0G!38#B2rQgZaT}6)|DgzhJ0*{DIUM>)6W&Wq&YWMbD_9OuqRvK1-xxn$>Vm>hyw5{g!%tdh;{N3_JM>o&X$dy;*(1$&RzGzTCsnNR)8yk zbss$Ge=8iF@*)S9gjF9CXW7O6iwRgaM1uL^#LnrqLRE`-QgZ7*70nb8OQ|+ho$xO&z#^qM)MK4Ro)e$Bz9thh*-ar^= z6osToTxqp}4=@gS5Xi4U4tJDeVl)uQy*`}%gX$pMFL|x;Bi0im@ix;I?Qb`Qy&>AfO#mTlVk7W`Ed$I0wlN4>2nNBi zbZ`ttgupmR$Bpn=Oy-;5mw<~wI>cbNgD2Q{B{m;nhzV~PtRQbhwzzJi4{X$=rIuI1 zj|D>7kJoiF;}BwpIVa8nGQ0-NZxp@?ZDMW^|FnSpYHV7@})*)yv@)_1*{aoHeNo<$Q419kPE;a-Q)3@;B3%Ksu z#uaU2`lx?ZOD9g}NL7}sS|+QK%0+9MtXd_HZ#1bEPZpYV)0ZZ?b5+$i4SVY^=G3#~ zEWNJZ1L@#peO-#o>JO~w4=fq~#<+U;!h>_?GlwrM8JCP7ZoFnK7gj76aEhX){{5HV zdHEM3bA7X2>-x&%6Ki^NYW%N5zYP5+cq!X4y4*3E>F~~y>tz*h9hg;oR%1>z{^BLfR<5$< zR_I3Ptq4wB8?U{dYFw>v`DFUj*FS#!-sNoDz)IV|XNEoN#)?}f-aL`?EDmRl$5xET z?pCdyaKZ;MrA)4BYI2(L`GL6s%mL(*{3%zu`C_`-x2Exbqery|zp6re4*X9OQX1Bk z+FAd6U@nkU!nS@ST~ggvJy5nl^MV8t;8SlsvtUn~TYuAXcj_OXx!<|^bl=BRdeD__ z9ZnC2(z@_!Y$_XjeL42}e*-{vN+#@BNe=Up(;x#Ak>~e zbM;KR>bZ=jGf|RLYv