Initial GameCult Bifrost Reddit bridge
This commit is contained in:
commit
9a89410d90
26 changed files with 6765 additions and 0 deletions
18
.github/dependabot.yml
vendored
Normal file
18
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Dependabot configuration to update npm packages weekly
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/" # Location of your package.json and package-lock.json
|
||||
schedule:
|
||||
interval: "cron"
|
||||
cronjob: "0 17 * * 1,2" # At 5pm on Monday and Tuesday - https://crontab.guru/
|
||||
timezone: "America/New_York"
|
||||
open-pull-requests-limit: 10 # Optional: prevents PR spam by limiting active updates
|
||||
ignore:
|
||||
- dependency-name: "@types/node"
|
||||
update-types: ["version-update:semver-major"]
|
||||
groups:
|
||||
devvit:
|
||||
patterns:
|
||||
- "devvit"
|
||||
- "@devvit/*"
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
.env
|
||||
public/*.js
|
||||
public/*.js.map
|
||||
devvit-playtest*.log
|
||||
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
22.6.0
|
||||
26
LICENSE
Normal file
26
LICENSE
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# License (BSD-3-Clause)
|
||||
|
||||
Copyright (c) 2025 Reddit Inc.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND ANY
|
||||
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE FOR ANY
|
||||
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
75
README.md
Normal file
75
README.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# GameCult Bifrost Reddit Bridge
|
||||
|
||||
Devvit app for the Reddit-side actuator of Bifrost public transport.
|
||||
|
||||
Bifrost owns governed GameCult crossings. This app gives Bifrost a Reddit-native
|
||||
body that can create subreddit posts and comments through Devvit, then return
|
||||
receipt-shaped JSON for the canonical Bifrost topic/comment store.
|
||||
|
||||
## Authority
|
||||
|
||||
- Owner: Bifrost.
|
||||
- Provider body: Reddit Devvit app `GameCult-Bifrost`.
|
||||
- Current app directory: `E:\Projects\gamecult-bifrost`.
|
||||
- Canonical discussion/approval: Bifrost governance topics.
|
||||
- Public action: Reddit posts and comments executed as the Devvit app account.
|
||||
- Receipt: `bifrost.reddit.receipt` JSON returned by the bridge endpoint.
|
||||
|
||||
Personas should not call Reddit directly. They create Bifrost-governed intents;
|
||||
Bifrost routes authorized Reddit crossings through this app and records the
|
||||
permalink receipt back on the canonical topic.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /api/health`
|
||||
- `POST /internal/bifrost/reddit-post`
|
||||
- `POST /internal/bifrost/reddit-comment`
|
||||
- `POST /internal/menu/post-create`
|
||||
- `POST /internal/on-app-install`
|
||||
|
||||
Set `BIFROST_REDDIT_BRIDGE_SECRET` to require callers to send
|
||||
`x-bifrost-bridge-secret`. The secret check is deliberately small because
|
||||
Heimdall should own long-term credential custody and capability claims.
|
||||
|
||||
### Reddit Post Request
|
||||
|
||||
```json
|
||||
{
|
||||
"actor": "nibu",
|
||||
"source": "bifrost-topic:topic_...",
|
||||
"subredditName": "GameCult",
|
||||
"title": "Thread title",
|
||||
"text": "Markdown body",
|
||||
"idempotencyKey": "optional stable key"
|
||||
}
|
||||
```
|
||||
|
||||
### Reddit Comment Request
|
||||
|
||||
```json
|
||||
{
|
||||
"actor": "nibu",
|
||||
"source": "bifrost-topic-comment:comment_...",
|
||||
"parentId": "t3_...",
|
||||
"text": "Markdown body",
|
||||
"idempotencyKey": "optional stable key"
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```powershell
|
||||
npm run type-check
|
||||
npm run build
|
||||
npm run dev
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
`npm run dev` starts a Reddit playtest session. `npm run deploy` builds and
|
||||
uploads a new app version.
|
||||
|
||||
## Next Cut
|
||||
|
||||
Wire the main `E:\Projects\Bifrost` bridge tooling to enqueue or invoke these
|
||||
Reddit actions from canonical governance topic comments, then store the returned
|
||||
`bifrost.reddit.receipt` on the topic as the public proof.
|
||||
41
devvit.json
Normal file
41
devvit.json
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"$schema": "https://developers.reddit.com/schema/config-file.v1.json",
|
||||
"name": "gamecult-bifrost",
|
||||
"post": {
|
||||
"dir": "public",
|
||||
"entrypoints": {
|
||||
"default": {
|
||||
"inline": true,
|
||||
"entry": "splash.html"
|
||||
},
|
||||
"game": {
|
||||
"entry": "game.html"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"dir": "dist/server",
|
||||
"entry": "index.js"
|
||||
},
|
||||
"menu": {
|
||||
"items": [
|
||||
{
|
||||
"label": "Create Bifrost bridge hub",
|
||||
"description": "Create a Bifrost-owned Reddit bridge status post",
|
||||
"location": "subreddit",
|
||||
"forUserType": "moderator",
|
||||
"endpoint": "/internal/menu/post-create"
|
||||
}
|
||||
]
|
||||
},
|
||||
"triggers": {
|
||||
"onAppInstall": "/internal/on-app-install"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node --experimental-strip-types ./tools/build.ts --watch",
|
||||
"build": "node --experimental-strip-types ./tools/build.ts --minify"
|
||||
},
|
||||
"dev": {
|
||||
"subreddit": "gamecult_bifrost_dev"
|
||||
}
|
||||
}
|
||||
5851
package-lock.json
generated
Normal file
5851
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
package.json
Normal file
27
package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"private": true,
|
||||
"name": "gamecult-bifrost",
|
||||
"version": "0.0.0",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node --experimental-strip-types ./tools/build.ts --minify",
|
||||
"deploy": "npm run build && devvit upload",
|
||||
"dev": "devvit playtest",
|
||||
"login": "devvit login",
|
||||
"launch": "npm run build && npm run deploy && devvit publish",
|
||||
"type-check": "tsc --build"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@devvit/web": "0.13.3",
|
||||
"devvit": "0.13.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.19.19",
|
||||
"typescript": "6.0.3",
|
||||
"esbuild": "0.28.0"
|
||||
}
|
||||
}
|
||||
69
public/game.css
Normal file
69
public/game.css
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
|
||||
Arial, sans-serif;
|
||||
}
|
||||
|
||||
.app {
|
||||
align-items: center;
|
||||
background: #f7f3ea;
|
||||
color: #171717;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-width: 560px;
|
||||
text-align: left;
|
||||
width: min(100%, 560px);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #8a2f1b;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #24513a;
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ledger {
|
||||
border-left: 3px solid #1c3f5f;
|
||||
color: #3d3a35;
|
||||
display: grid;
|
||||
font-size: 0.92rem;
|
||||
gap: 8px;
|
||||
line-height: 1.35;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
bottom: 16px;
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #1c3f5f;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
28
public/game.html
Normal file
28
public/game.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="game.css" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, width=device-width, height=device-height, user-scalable=0"
|
||||
/>
|
||||
<title>Bifrost Reddit Bridge</title>
|
||||
</head>
|
||||
<body class="app">
|
||||
<main class="content">
|
||||
<p class="eyebrow">GameCult Bifrost</p>
|
||||
<h1 class="title">Reddit bridge status</h1>
|
||||
<p class="description" id="bridge-status">Checking bridge actuator...</p>
|
||||
<section class="ledger">
|
||||
<p>Owner: Bifrost</p>
|
||||
<p>Actions: reddit-post, reddit-comment</p>
|
||||
<p>Receipts: returned to canonical Bifrost topics</p>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<button class="text-link" id="docs-link">Devvit docs</button>
|
||||
</footer>
|
||||
<script type="module" src="game.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
public/snoo.png
Normal file
BIN
public/snoo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
69
public/splash.css
Normal file
69
public/splash.css
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
|
||||
Arial, sans-serif;
|
||||
}
|
||||
|
||||
.app {
|
||||
align-items: center;
|
||||
background: #f7f3ea;
|
||||
color: #171717;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-width: 520px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #8a2f1b;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #4c4a45;
|
||||
font-size: 1rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.open-button {
|
||||
background: #1c3f5f;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
justify-self: center;
|
||||
min-height: 36px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
bottom: 16px;
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #1c3f5f;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
27
public/splash.html
Normal file
27
public/splash.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" type="text/css" href="splash.css" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, width=device-width, height=device-height, user-scalable=0"
|
||||
/>
|
||||
<title>Bifrost Reddit Bridge</title>
|
||||
</head>
|
||||
<body class="app">
|
||||
<main class="content">
|
||||
<p class="eyebrow">GameCult Bifrost</p>
|
||||
<h1 class="title">Reddit bridge actuator</h1>
|
||||
<p class="description">
|
||||
Governed posts and comments originate in Bifrost topics, then land here
|
||||
with receipts.
|
||||
</p>
|
||||
<button class="open-button" id="open-button">Open status</button>
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<button class="text-link" id="docs-link">Devvit docs</button>
|
||||
</footer>
|
||||
<script type="module" src="splash.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
26
src/client/game.ts
Normal file
26
src/client/game.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { navigateTo } from "@devvit/web/client";
|
||||
import { ApiEndpoint, type HealthResponse } from "../shared/api.ts";
|
||||
|
||||
const statusElement = document.getElementById("bridge-status");
|
||||
|
||||
document.getElementById("docs-link")?.addEventListener("click", () => {
|
||||
navigateTo("https://developers.reddit.com/docs");
|
||||
});
|
||||
|
||||
async function refreshStatus(): Promise<void> {
|
||||
if (!statusElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(ApiEndpoint.Health);
|
||||
const data = (await response.json()) as HealthResponse;
|
||||
statusElement.textContent =
|
||||
data.status === "ok" ? "Bridge actuator online" : "Bridge status unknown";
|
||||
} catch (error) {
|
||||
console.error("Unable to read bridge status", error);
|
||||
statusElement.textContent = "Bridge status unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
void refreshStatus();
|
||||
9
src/client/splash.ts
Normal file
9
src/client/splash.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { navigateTo, requestExpandedMode } from "@devvit/web/client";
|
||||
|
||||
document.getElementById("open-button")?.addEventListener("click", (event) => {
|
||||
requestExpandedMode(event, "game");
|
||||
});
|
||||
|
||||
document.getElementById("docs-link")?.addEventListener("click", () => {
|
||||
navigateTo("https://developers.reddit.com/docs");
|
||||
});
|
||||
8
src/server/index.ts
Normal file
8
src/server/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { createServer, getServerPort } from "@devvit/web/server";
|
||||
import { serverOnRequest } from "./server.ts";
|
||||
|
||||
const server = createServer(serverOnRequest);
|
||||
const port: number = getServerPort();
|
||||
|
||||
server.on("error", (err) => console.error(`server error; ${err.stack}`));
|
||||
server.listen(port);
|
||||
267
src/server/server.ts
Normal file
267
src/server/server.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { context, reddit } from "@devvit/web/server";
|
||||
import type {
|
||||
PartialJsonValue,
|
||||
TriggerResponse,
|
||||
UiResponse,
|
||||
} from "@devvit/web/shared";
|
||||
import {
|
||||
ApiEndpoint,
|
||||
type BridgeCommentRequest,
|
||||
type BridgePostRequest,
|
||||
type BridgeReceipt,
|
||||
type HealthResponse,
|
||||
} from "../shared/api.ts";
|
||||
import { once } from "node:events";
|
||||
|
||||
export async function serverOnRequest(
|
||||
req: IncomingMessage,
|
||||
rsp: ServerResponse,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await onRequest(req, rsp);
|
||||
} catch (err) {
|
||||
if (err instanceof BridgeError) {
|
||||
writeJSON<ErrorResponse>(
|
||||
err.status,
|
||||
{ error: err.message, status: err.status },
|
||||
rsp,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = `server error; ${err instanceof Error ? err.stack : err}`;
|
||||
console.error(msg);
|
||||
writeJSON<ErrorResponse>(500, { error: msg, status: 500 }, rsp);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRequest(
|
||||
req: IncomingMessage,
|
||||
rsp: ServerResponse,
|
||||
): Promise<void> {
|
||||
const endpoint = getEndpoint(req.url);
|
||||
if (!endpoint) {
|
||||
writeJSON<ErrorResponse>(404, { error: "not found", status: 404 }, rsp);
|
||||
return;
|
||||
}
|
||||
|
||||
let body: BridgeReceipt | HealthResponse | UiResponse | TriggerResponse;
|
||||
switch (endpoint) {
|
||||
case ApiEndpoint.Health:
|
||||
body = { type: "health", app: "gamecult-bifrost", status: "ok" };
|
||||
break;
|
||||
case ApiEndpoint.BridgePost:
|
||||
assertBridgeSecret(req);
|
||||
body = await onBridgePost(req);
|
||||
break;
|
||||
case ApiEndpoint.BridgeComment:
|
||||
assertBridgeSecret(req);
|
||||
body = await onBridgeComment(req);
|
||||
break;
|
||||
case ApiEndpoint.OnPostCreate:
|
||||
body = await onMenuNewPost();
|
||||
break;
|
||||
case ApiEndpoint.OnAppInstall:
|
||||
body = await onAppInstall();
|
||||
break;
|
||||
default:
|
||||
endpoint satisfies never;
|
||||
writeJSON<ErrorResponse>(404, { error: "not found", status: 404 }, rsp);
|
||||
return;
|
||||
}
|
||||
|
||||
writeJSON<PartialJsonValue>(200, body, rsp);
|
||||
}
|
||||
|
||||
type ErrorResponse = {
|
||||
error: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
async function onBridgePost(req: IncomingMessage): Promise<BridgeReceipt> {
|
||||
const request = validatePostRequest(await readJSON<BridgePostRequest>(req));
|
||||
const post = await reddit.submitPost({
|
||||
subredditName: request.subredditName,
|
||||
title: request.title,
|
||||
text: renderGovernedBody(request.text, request),
|
||||
sendreplies: false,
|
||||
runAs: "APP",
|
||||
});
|
||||
|
||||
return {
|
||||
type: "bifrost.reddit.receipt",
|
||||
action: "reddit-post",
|
||||
actor: request.actor,
|
||||
source: request.source,
|
||||
target: `r/${request.subredditName}`,
|
||||
redditId: post.id,
|
||||
url: post.url || redditPermalink(post.permalink),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function onBridgeComment(req: IncomingMessage): Promise<BridgeReceipt> {
|
||||
const request = validateCommentRequest(
|
||||
await readJSON<BridgeCommentRequest>(req),
|
||||
);
|
||||
const comment = await reddit.submitComment({
|
||||
id: normalizeThingId(request.parentId),
|
||||
text: renderGovernedBody(request.text, request),
|
||||
runAs: "APP",
|
||||
});
|
||||
|
||||
return {
|
||||
type: "bifrost.reddit.receipt",
|
||||
action: "reddit-comment",
|
||||
actor: request.actor,
|
||||
source: request.source,
|
||||
target: request.parentId,
|
||||
redditId: comment.id,
|
||||
url: comment.url || redditPermalink(comment.permalink),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function onMenuNewPost(): Promise<UiResponse> {
|
||||
const subreddit = await reddit.getCurrentSubreddit();
|
||||
const post = await reddit.submitCustomPost({
|
||||
subredditName: subreddit.name,
|
||||
title: "Bifrost bridge intake",
|
||||
textFallback: {
|
||||
text:
|
||||
"Bifrost owns governed GameCult crossings into Reddit. " +
|
||||
"Canonical proposals, approvals, and receipts remain in Bifrost.",
|
||||
},
|
||||
postData: {
|
||||
kind: "gamecult-bifrost-bridge-hub",
|
||||
createdBy: context.username ?? "unknown",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
showToast: { text: `Bridge hub ${post.id} created.`, appearance: "success" },
|
||||
navigateTo: post.url || redditPermalink(post.permalink),
|
||||
};
|
||||
}
|
||||
|
||||
async function onAppInstall(): Promise<TriggerResponse> {
|
||||
const subreddit = await reddit.getCurrentSubreddit();
|
||||
await reddit.submitCustomPost({
|
||||
subredditName: subreddit.name,
|
||||
title: "Bifrost bridge online",
|
||||
textFallback: {
|
||||
text:
|
||||
"The GameCult Bifrost Reddit bridge is installed. " +
|
||||
"Bridge actions must still originate from governed Bifrost topics.",
|
||||
},
|
||||
postData: {
|
||||
kind: "gamecult-bifrost-install-receipt",
|
||||
},
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function getEndpoint(url: string | undefined): ApiEndpoint | undefined {
|
||||
if (!url || url === "/") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pathname = new URL(url, "https://devvit.local").pathname;
|
||||
return Object.values(ApiEndpoint).find((endpoint) => endpoint === pathname);
|
||||
}
|
||||
|
||||
function assertBridgeSecret(req: IncomingMessage): void {
|
||||
const expectedSecret = process.env.BIFROST_REDDIT_BRIDGE_SECRET;
|
||||
if (!expectedSecret) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actualSecret = req.headers["x-bifrost-bridge-secret"];
|
||||
if (actualSecret !== expectedSecret) {
|
||||
throw new BridgeError(401, "invalid bridge secret");
|
||||
}
|
||||
}
|
||||
|
||||
function validatePostRequest(request: BridgePostRequest): BridgePostRequest {
|
||||
assertNonEmpty(request.actor, "actor");
|
||||
assertNonEmpty(request.source, "source");
|
||||
assertNonEmpty(request.subredditName, "subredditName");
|
||||
assertNonEmpty(request.title, "title");
|
||||
assertNonEmpty(request.text, "text");
|
||||
return request;
|
||||
}
|
||||
|
||||
function validateCommentRequest(
|
||||
request: BridgeCommentRequest,
|
||||
): BridgeCommentRequest {
|
||||
assertNonEmpty(request.actor, "actor");
|
||||
assertNonEmpty(request.source, "source");
|
||||
assertNonEmpty(request.parentId, "parentId");
|
||||
assertNonEmpty(request.text, "text");
|
||||
return request;
|
||||
}
|
||||
|
||||
function assertNonEmpty(value: unknown, field: string): asserts value is string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new BridgeError(400, `missing ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeThingId(id: string): `t1_${string}` | `t3_${string}` {
|
||||
if (id.startsWith("t1_") || id.startsWith("t3_")) {
|
||||
return id as `t1_${string}` | `t3_${string}`;
|
||||
}
|
||||
|
||||
throw new BridgeError(400, "parentId must be a t1_ comment or t3_ post id");
|
||||
}
|
||||
|
||||
function renderGovernedBody(
|
||||
body: string,
|
||||
request: BridgePostRequest | BridgeCommentRequest,
|
||||
): string {
|
||||
const idempotency = request.idempotencyKey
|
||||
? `\n\nBifrost idempotency: ${request.idempotencyKey}`
|
||||
: "";
|
||||
|
||||
return `${body.trim()}\n\n---\nBifrost source: ${request.source}\nActor: ${request.actor}${idempotency}`;
|
||||
}
|
||||
|
||||
function redditPermalink(permalink: string): string {
|
||||
if (permalink.startsWith("http")) {
|
||||
return permalink;
|
||||
}
|
||||
|
||||
return `https://www.reddit.com${permalink}`;
|
||||
}
|
||||
|
||||
function writeJSON<T extends PartialJsonValue>(
|
||||
status: number,
|
||||
json: Readonly<T>,
|
||||
rsp: ServerResponse,
|
||||
): void {
|
||||
const body = JSON.stringify(json);
|
||||
const len = Buffer.byteLength(body);
|
||||
rsp.writeHead(status, {
|
||||
"Content-Length": len,
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
rsp.end(body);
|
||||
}
|
||||
|
||||
async function readJSON<T>(req: IncomingMessage): Promise<T> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
req.on("data", (chunk) => chunks.push(chunk));
|
||||
await once(req, "end");
|
||||
return JSON.parse(`${Buffer.concat(chunks)}`);
|
||||
}
|
||||
|
||||
class BridgeError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
43
src/shared/api.ts
Normal file
43
src/shared/api.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
export const ApiEndpoint = {
|
||||
Health: "/api/health",
|
||||
BridgePost: "/internal/bifrost/reddit-post",
|
||||
BridgeComment: "/internal/bifrost/reddit-comment",
|
||||
OnPostCreate: "/internal/menu/post-create",
|
||||
OnAppInstall: "/internal/on-app-install",
|
||||
} as const;
|
||||
|
||||
export type ApiEndpoint = (typeof ApiEndpoint)[keyof typeof ApiEndpoint];
|
||||
|
||||
export type HealthResponse = {
|
||||
type: "health";
|
||||
app: "gamecult-bifrost";
|
||||
status: "ok";
|
||||
};
|
||||
|
||||
export type BridgePostRequest = {
|
||||
actor: string;
|
||||
source: string;
|
||||
subredditName: string;
|
||||
title: string;
|
||||
text: string;
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
export type BridgeCommentRequest = {
|
||||
actor: string;
|
||||
source: string;
|
||||
parentId: string;
|
||||
text: string;
|
||||
idempotencyKey?: string;
|
||||
};
|
||||
|
||||
export type BridgeReceipt = {
|
||||
type: "bifrost.reddit.receipt";
|
||||
action: "reddit-post" | "reddit-comment";
|
||||
actor: string;
|
||||
source: string;
|
||||
target: string;
|
||||
redditId: string;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
};
|
||||
56
tools/build.ts
Normal file
56
tools/build.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env -S node --experimental-strip-types
|
||||
|
||||
// Bundles sources to dist/ and public/.
|
||||
//
|
||||
// build.ts [--minify] [--watch]
|
||||
// --local Run development server. Serve on http://localhost:1234 and reload on
|
||||
// code change.
|
||||
// --minify Minify output.
|
||||
// --watch Automatically rebuild whenever an input changes.
|
||||
|
||||
import fs from "node:fs";
|
||||
import type { BuildOptions } from "esbuild";
|
||||
import esbuild from "esbuild";
|
||||
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const opts: BuildOptions = {
|
||||
bundle: true,
|
||||
logLevel: "info", // Print the port and build demarcations.
|
||||
metafile: true,
|
||||
sourcemap: "linked",
|
||||
target: "es2023", // https://esbuild.github.io/content-types/#tsconfig-json
|
||||
};
|
||||
|
||||
const clientOpts: BuildOptions = {
|
||||
...opts,
|
||||
entryPoints: ["src/client/splash.ts", "src/client/game.ts"],
|
||||
format: "esm",
|
||||
outdir: "public",
|
||||
platform: "browser",
|
||||
};
|
||||
const serverOpts: BuildOptions = {
|
||||
...opts,
|
||||
entryPoints: ["src/server/index.ts"],
|
||||
format: "cjs",
|
||||
outdir: "dist/server",
|
||||
platform: "node",
|
||||
};
|
||||
|
||||
if (watch) {
|
||||
const clientCtx = await esbuild.context(clientOpts);
|
||||
const serverCtx = await esbuild.context(serverOpts);
|
||||
await Promise.all([
|
||||
watch ? clientCtx.watch() : undefined,
|
||||
watch ? serverCtx.watch() : undefined,
|
||||
]);
|
||||
} else {
|
||||
const [client, server] = await Promise.all([
|
||||
esbuild.build(clientOpts),
|
||||
esbuild.build(serverOpts),
|
||||
]);
|
||||
if (client.metafile)
|
||||
fs.writeFileSync("dist/client.meta.json", JSON.stringify(client.metafile));
|
||||
if (server.metafile)
|
||||
fs.writeFileSync("dist/server.meta.json", JSON.stringify(server.metafile));
|
||||
}
|
||||
57
tools/tsconfig.base.json
Normal file
57
tools/tsconfig.base.json
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// Enable incremental builds.
|
||||
"composite": true,
|
||||
|
||||
// Enable type-stripping.
|
||||
"allowImportingTsExtensions": true,
|
||||
|
||||
// Only allow type-strippable syntax.
|
||||
"erasableSyntaxOnly": true,
|
||||
|
||||
// Maximize type checking.
|
||||
"allowUnreachableCode": false,
|
||||
"allowUnusedLabels": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"noUnusedLocals": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
|
||||
// Projects add types needed.
|
||||
"lib": ["ES2023"],
|
||||
"types": [],
|
||||
|
||||
"isolatedDeclarations": true, // Require explicit types.
|
||||
|
||||
// Improve compatibility with compilers that aren't type system aware.
|
||||
"isolatedModules": true,
|
||||
|
||||
"esModuleInterop": true, // Use ESM.
|
||||
|
||||
// Allow JSON type-checking and imports.
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
|
||||
"rewriteRelativeImportExtensions": true, // Rewrite .ts imports to .js.
|
||||
|
||||
// All subprojects output to dist/ relative the project root.
|
||||
"outDir": "../dist",
|
||||
"rootDir": "..",
|
||||
|
||||
// Assume library types are checked and compatible.
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
|
||||
"sourceMap": true,
|
||||
"declarationMap": true, // Map back to original sources, not .d.ts.
|
||||
|
||||
"target": "ES2023",
|
||||
|
||||
// Don't transform type-only imports.
|
||||
"verbatimModuleSyntax": true
|
||||
}
|
||||
}
|
||||
17
tools/tsconfig.client.json
Normal file
17
tools/tsconfig.client.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../dist/types/client",
|
||||
"lib": ["DOM", "ES2023"],
|
||||
"tsBuildInfoFile": "../dist/types/client/tsconfig.tsbuildinfo",
|
||||
"customConditions": ["browser"],
|
||||
"rootDir": "../src/client"
|
||||
},
|
||||
"include": ["../src/client/**/*"],
|
||||
"exclude": ["../src/server/**/*"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.shared.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
tools/tsconfig.json
Normal file
8
tools/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../dist/types/server",
|
||||
"tsBuildInfoFile": "../dist/types/tools/tsconfig.tsbuildinfo",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
16
tools/tsconfig.server.json
Normal file
16
tools/tsconfig.server.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../dist/types/server",
|
||||
"tsBuildInfoFile": "../dist/types/server/tsconfig.tsbuildinfo",
|
||||
"customConditions": ["server"],
|
||||
"types": ["node"],
|
||||
"rootDir": "../src/server"
|
||||
},
|
||||
"include": ["../src/server/**/*"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.shared.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
tools/tsconfig.shared.json
Normal file
10
tools/tsconfig.shared.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../dist/types/shared",
|
||||
"tsBuildInfoFile": "../dist/types/shared/tsconfig.tsbuildinfo",
|
||||
"rootDir": "../src/shared"
|
||||
},
|
||||
"include": ["../src/shared/**/*"],
|
||||
"exclude": []
|
||||
}
|
||||
8
tsconfig.json
Normal file
8
tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tools/tsconfig.client.json" },
|
||||
{ "path": "./tools/tsconfig.server.json" },
|
||||
{ "path": "./tools/tsconfig.shared.json" }
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue