Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ BOT_RPC_TOKEN="自分で生成したランダムな値(両プロセスで同
# presentation-web プロセス側のみが参照
BOT_RPC_URL="http://localhost:8083"

# presentation-web -> presentation-verify 間の内部Connect RPC用(認証時のTurnstile/VPN判定を行う)。
# presentation-web側のVERIFY_RPC_URLは未指定ならhttp://localhost:8084になる
VERIFY_RPC_PORT="8084"
VERIFY_RPC_TOKEN="自分で生成したランダムな値(両プロセスで同じ値にする)"

SENTRY_ORG="Sentryの組織スラッグ"
SENTRY_PROJECT="Sentryのプロジェクトスラッグ"
PUBLIC_SENTRY_DSN="SentryのDSN"
Expand Down
18 changes: 11 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -649,9 +649,14 @@ the same shape for the next one, and note that they don't all share one directio
- `presentation-verify` (`VerifyService`, `proto/distopia/verify/v1/verify.proto`) — a
different shape from the other three in what it's *for*, not where it runs: it **is**
built and deployed from this repo (same image as `presentation-web`/`presentation-bot`,
`command` overridden — see `bot-deployment.yaml`'s own precedent), but it's **optional**
and implements nothing itself. Every method (`src/presentation/verify/src/server/rpc.ts`)
delegates to `src/presentation/verify/src/plugin.ts`, which delegates to `infra-verify`'s
`command` overridden — see `bot-deployment.yaml`'s own precedent) and is **required** for
member verification; only its plugin folder is optional. `Evaluate`
(`src/presentation/verify/src/server/rpc.ts` → `checks.ts`) runs the whole server-side
decision — the guild's opt-in VPN/proxy/Tor block (`infra-aip`), the Cloudflare Turnstile
check (`TURNSTILE_SECRET_KEY` lives on this service, not on web), then the plugin's own
`evaluate` — and stops at the first refusal, returning `allowed=false` plus a `reason` such
as `Used VPN` or `Turnstile verification failed` (plus the raw VPN result for logging).
The other methods delegate to `src/presentation/verify/src/plugin.ts`, which delegates to `infra-verify`'s
`loadPluginFolder` (`src/infrastructure/verify/src/pluginLoader.ts`) — a generic loader
(not in `presentation-verify` itself, so it's reusable) that scans a *folder*
(`VERIFY_PLUGIN_DIR`, default `src/presentation/verify/plugins/`) for non-dot-prefixed
Expand All @@ -670,10 +675,9 @@ the same shape for the next one, and note that they don't all share one directio
script never bundled by Vite (unlike `presentation-web`, where the same trick would fail:
Vite statically resolves a dynamic import at *build* time and errors if the target doesn't
exist). `presentation-web` (`lib/server/verifyPlugin.ts`) is the
RPC *client* of this service and falls back to always-allow whenever
`VERIFY_RPC_URL`/`VERIFY_RPC_TOKEN` aren't configured on it — independent of whether
`distopia-verify` itself is deployed or has a plugin mounted, so there's no broken
intermediate state. The `distopia` npm/jsr package re-exports the RPC contract
RPC *client* of this service and refuses every verification attempt (reason `Verification
service is not configured`) whenever `VERIFY_RPC_TOKEN` isn't configured
on it (`VERIFY_RPC_URL` defaults to the in-cluster Service via `configmap.yaml`); whether a plugin is mounted on `distopia-verify` is independent of that. The `distopia` npm/jsr package re-exports the RPC contract
(`lib/distopia/src/rpc.ts`) and the plugin contract (`lib/distopia/src/plugin.ts` —
`VerifyServerPlugin`/`VerifyClientPlugin`) so a plugin file can be authored and
type-checked anywhere, without this monorepo. Even the anti-scraping body cipher is
Expand Down
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 18 additions & 17 deletions k8s/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -701,12 +701,13 @@ from two pods at once causes duplicate event handling. `distopia-redis-allow-int
`distopia-verify` is a different shape from the three splits above, but less different than
it first looks: it **is** built from this repo, same image as `distopia-app`/`distopia-bot`
(`verify-deployment.yaml`'s `command` just runs `presentation-verify` instead), and it is
entirely **optional**. Member verification (`/verify-admin`,
`src/presentation/web/src/routes/verify/[guildId]`) only needs two things this public repo
already ships on its own — Cloudflare Turnstile and the existing `role.give`/`role.deprive`
RPC through `distopia-bot` — to work end to end. `VerifyService` (`infra-rpc`'s
`verify.proto`, re-exported publicly as types through the `distopia` npm/jsr package) exists
purely as an *optional extra signal* on top of that.
**required** for member verification (`/verify-admin`,
`src/presentation/web/src/routes/verify/[guildId]`): its `Evaluate` RPC runs the guild's
opt-in VPN/proxy/Tor block, then Cloudflare Turnstile (so `TURNSTILE_SECRET_KEY` is read by
`distopia-verify`, from the same `distopia-env` Secret), then the plugin, and on a refusal
returns a `reason` (`Used VPN`, `Turnstile verification failed`, ...) that `distopia-app`
shows to the user and writes to the guild's verify log. Role grant/removal still goes
through `distopia-bot`'s RPC from `distopia-app`. Only the *plugin* is optional.

What's actually private isn't a separate repo or image — it's the **contents of a folder**
(`src/presentation/verify/plugins/`, `VERIFY_PLUGIN_DIR` env var to override), mounted into
Expand All @@ -719,11 +720,12 @@ file, alphabetically, that defines them). The folder ships in the image with exa
files, both dot-prefixed and therefore always skipped: `plugins/.fingerprint` and
`plugins/.anti-scraping` -- inert samples showing the expected shape (no real logic in
either), not loaded by anything until you copy one to a non-dot filename and actually
implement it. `presentation-verify` itself holds no anti-bot/fingerprinting logic at all --
every `VerifyService` method (`src/presentation/verify/src/server/rpc.ts`) just delegates to
implement it. `presentation-verify` itself holds no anti-bot/fingerprinting logic --
beyond the Turnstile and VPN checks in `checks.ts`, every `VerifyService` method
(`src/presentation/verify/src/server/rpc.ts`) just delegates to
`plugin.ts`, which delegates to `loadPluginFolder`, and falls back to allow/empty/fail-closed
the same way whether the Secret is missing, the whole `distopia-verify` Deployment was never
applied, or `distopia-app`'s own `VERIFY_RPC_URL`/`VERIFY_RPC_TOKEN` are unset. This works
when no plugin Secret is mounted. If `distopia-verify` was never applied or `distopia-app`'s
own `VERIFY_RPC_TOKEN` is unset, every verification attempt is refused. This works
because `presentation-verify` is a plain Bun script like `presentation-bot`/
`presentation-schedulemanager` — never bundled by Vite — so a dynamic `import()` of a path
that may or may not exist on disk really is resolved at runtime, the same trick
Expand Down Expand Up @@ -779,17 +781,16 @@ kubectl create secret generic distopia-verify-plugin -n distopia \

# 2. Add VERIFY_RPC_TOKEN to distopia-env (same value verify-deployment.yaml's own
# secretKeyRef reads -- re-run the distopia-env creation command in section 2 above with
# this one extra flag added) and VERIFY_RPC_URL to distopia-app's config:
# this one extra flag added). VERIFY_RPC_URL needs no setup: k8s/app/configmap.yaml
# already points distopia-app at http://distopia-verify:8084.
# --from-literal=VERIFY_RPC_TOKEN='<a random value, shared by both sides>'
# kubectl edit configmap distopia-config -n distopia # add VERIFY_RPC_URL: "http://distopia-verify:8084"
# then roll both Deployments (kubectl rollout restart deployment/distopia-app
# deployment/distopia-verify -n distopia) so they pick up the new env vars.
# deployment/distopia-verify -n distopia) so they pick up the new env var.
```

Until step 2 is done, `distopia-app` simply never attempts the RPC call at all
(`VERIFY_RPC_URL`/`VERIFY_RPC_TOKEN` both have to be set for `verifyPlugin.ts` to even
construct a client) — there's no partial/broken state to worry about, and step 1 can happen
before or after it, or be skipped entirely (no plugin mounted is also a fully valid state).
Until step 2 is done, `distopia-app` has no `VERIFY_RPC_TOKEN` and refuses every verification
attempt (reason `Verification service is not configured`); step 1 can happen before or after
it, or be skipped entirely (no plugin mounted is also a fully valid state).

## Notes / known constraints

Expand Down
5 changes: 5 additions & 0 deletions k8s/app/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
# distopia-app calls it (see bot-deployment.yaml/bot-networkpolicy.yaml and
# src/presentation/web/src/lib/server/discord.ts). Change them together with
# bot-deployment.yaml's containerPort and bot-service.yaml's `rpc` port.
#
# VERIFY_RPC_URL is how distopia-app reaches distopia-verify (verify-deployment.yaml/
# verify-service.yaml, port 8084). Committed here so it needs no manual setup; only
# VERIFY_RPC_TOKEN (a Secret) still has to be created.
apiVersion: v1
kind: ConfigMap
metadata:
Expand All @@ -38,3 +42,4 @@ data:
SEARCHENGINE_RPC_URL: "http://distopia-searchengine:8082"
BOT_RPC_PORT: "8083"
BOT_RPC_URL: "http://distopia-bot:8083"
VERIFY_RPC_URL: "http://distopia-verify:8084"
15 changes: 10 additions & 5 deletions k8s/app/verify-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,10 @@
# plugin file(s) for this Pod. See k8s/README.md's "Verify RPC" section and infra-rpc's
# verify.proto.
#
# Entirely optional: distopia-app (src/presentation/web/src/lib/server/verifyPlugin.ts) falls
# back to always-allow whenever VERIFY_RPC_URL/VERIFY_RPC_TOKEN aren't set on it, so nothing
# here needs to exist for member verification (Turnstile + role grant) to work. Deploying
# this with no plugin Secret mounted is also fine (every method just falls back the same way)
# -- deploy it to get the wiring in place, mount the Secret whenever you have a plugin ready.
# Required for member verification: distopia-app (src/presentation/web/src/lib/server/verifyPlugin.ts)
# refuses every attempt when VERIFY_RPC_TOKEN isn't set on it, because the VPN
# check and Cloudflare Turnstile run in this service (TURNSTILE_SECRET_KEY below). Only the
# plugin Secret is optional: with none mounted, the plugin step just allows.
apiVersion: apps/v1
kind: Deployment
metadata:
Expand Down Expand Up @@ -61,6 +60,12 @@ spec:
secretKeyRef:
name: distopia-env
key: VERIFY_RPC_TOKEN
# Used by checks.ts to verify the Turnstile token server-side.
- name: TURNSTILE_SECRET_KEY
valueFrom:
secretKeyRef:
name: distopia-env
key: TURNSTILE_SECRET_KEY
volumeMounts:
# Optional -- the one thing actually private here. `optional: true` means the
# Pod starts fine with no Secret at all, in which case this path just keeps the
Expand Down
4 changes: 2 additions & 2 deletions k8s/app/verify-service.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Internal-only: distopia-app resolves this as distopia-verify:8084 (set VERIFY_RPC_URL to
# that on distopia-app -- see k8s/README.md). Never wired to the Cloudflare Tunnel. See
# Internal-only: distopia-app resolves this as distopia-verify:8084 (VERIFY_RPC_URL in
# configmap.yaml). Never wired to the Cloudflare Tunnel. See
# verify-networkpolicy.yaml for the matching ingress restriction.
#
# Deliberately not pinning a clusterIP, same reasoning as service.yaml's own comment.
Expand Down
28 changes: 22 additions & 6 deletions lib/distopia/proto/distopia/verify/v1/verify.proto
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ syntax = "proto3";

package distopia.verify.v1;

// Implemented by a private, closed-source service that is NOT part of this repo (anti-bot/
// fingerprinting logic the operator deliberately keeps out of the public thunlights/distopia
// image) and called by presentation-web (lib/server/verifyPlugin.ts) during the member
// Implemented by presentation-verify (anti-bot/fingerprinting logic stays in an optional,
// privately-mounted plugin folder, never in the public image) and called by presentation-web (lib/server/verifyPlugin.ts) during the member
// verification flow. See k8s/README.md's "Verify RPC" section for how that service is wired
// up -- it's entirely optional; presentation-web falls back to always-allow when
// up -- it is required for member verification; presentation-web refuses every attempt when
// VERIFY_RPC_URL/VERIFY_RPC_TOKEN aren't configured. Generated here (not infra-rpc) so the
// `distopia` npm/jsr package (lib/distopia/src/rpc.ts) has zero workspace dependency of its
// own -- a prerequisite for `bun pm pack`/`npm publish` to work at all -- and infra-rpc
// depends on `distopia` for this one contract instead, the reverse of every other service.
service VerifyService {
// Extra server-side signal on top of the built-in Turnstile check. Return allowed=false to
// block verification.
// Runs the whole server-side verification decision: the optional VPN/proxy/Tor block, the
// Cloudflare Turnstile check, then the plugin's own evaluate(), in that order, stopping at the
// first rejection. allowed=false always carries a `reason` saying which step refused.
rpc Evaluate(EvaluateRequest) returns (EvaluateResponse) {}
// Raw JS source for the optional browser-side collector/cipher script, served to the
// client by presentation-web, which inlines it (and `wasm`) into the SSR'd verify page. Empty `code` means
Expand Down Expand Up @@ -52,9 +52,25 @@ message EvaluateRequest {
// (GetClientScript) returned from its own collect(), round-tripped here unchanged.
string extra_json = 3;
RequestContext request = 4;
// Cloudflare Turnstile token the browser obtained; single-use, verified by this service.
string turnstile_token = 5;
// The guild's opt-in VPN/proxy/Tor block (GuildVerify.blockVpn). When false the IP lookup is
// skipped entirely and `vpn` below comes back all-false.
bool block_vpn = 6;
}
message EvaluateResponse {
bool allowed = 1;
// Why verification was refused, e.g. "Used VPN", "Turnstile verification failed". Unset when
// allowed. Shown to the user and written to the guild's verify log.
optional string reason = 2;
// Raw VPN lookup outcome, returned even on success so presentation-web can log it.
VpnResult vpn = 3;
}
message VpnResult {
bool is_vpn = 1;
bool is_proxy = 2;
bool is_tor = 3;
optional string provider = 4;
}

message GetClientScriptRequest {}
Expand Down
1 change: 1 addition & 0 deletions lib/distopia/src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
type RequestContext,
type EvaluateRequest,
type EvaluateResponse,
type VpnResult,
type GetClientScriptRequest,
type GetClientScriptResponse,
type DecryptRequest,
Expand Down
1 change: 1 addition & 0 deletions src/infrastructure/rpc/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
type RequestContext,
type EvaluateRequest,
type EvaluateResponse,
type VpnResult,
type GetClientScriptRequest,
type GetClientScriptResponse,
type DecryptRequest,
Expand Down
2 changes: 2 additions & 0 deletions src/presentation/verify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"@connectrpc/connect": "catalog:",
"@connectrpc/connect-node": "catalog:",
"distopia": "workspace:*",
"infra-aip": "workspace:*",
"infra-http": "workspace:*",
"infra-rpc": "workspace:*",
"infra-verify": "workspace:*"
},
Expand Down
79 changes: 79 additions & 0 deletions src/presentation/verify/src/checks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("infra-aip", () => ({ checkVpn: vi.fn() }));
vi.mock("infra-http", async (importOriginal) => ({
...(await importOriginal<typeof import("infra-http")>()),
safeFetch: vi.fn(),
}));
vi.mock("./plugin", () => ({ evaluate: vi.fn() }));

import { checkVpn } from "infra-aip";
import { safeFetch } from "infra-http";

import { evaluate } from "./checks";
import * as plugin from "./plugin";

const input = {
guildId: "1",
userId: "2",
extra: null,
turnstileToken: "token",
blockVpn: true,
turnstileSecret: "secret",
request: { headers: {}, ip: "203.0.113.1" },
};

const clean = {
checked: true,
isVpn: false,
isProxy: false,
isTor: false,
provider: null,
} as const;
const turnstile = (success: boolean) => new Response(JSON.stringify({ success }), { status: 200 });

beforeEach(() => {
vi.mocked(checkVpn).mockResolvedValue(clean);
vi.mocked(safeFetch).mockResolvedValue(turnstile(true));
vi.mocked(plugin.evaluate).mockResolvedValue(true);
});

describe("evaluate", () => {
it("allows when every check passes", async () => {
expect(await evaluate(input)).toMatchObject({ allowed: true });
});

it("names the VPN as the reason", async () => {
vi.mocked(checkVpn).mockResolvedValue({ ...clean, isVpn: true, provider: "Acme" });
expect(await evaluate(input)).toMatchObject({
allowed: false,
reason: "Used VPN",
vpn: { isVpn: true, provider: "Acme" },
});
});

it("refuses without an IP or when the lookup fails, only if blockVpn is set", async () => {
expect(await evaluate({ ...input, request: { headers: {}, ip: null } })).toMatchObject({
reason: "Could not determine IP address",
});
vi.mocked(checkVpn).mockResolvedValue({ checked: false, error: "lookup_failed" });
expect(await evaluate(input)).toMatchObject({ reason: "VPN check failed" });
expect(await evaluate({ ...input, blockVpn: false })).toMatchObject({ allowed: true });
});

it("names Turnstile as the reason", async () => {
vi.mocked(safeFetch).mockResolvedValue(turnstile(false));
expect(await evaluate(input)).toMatchObject({
allowed: false,
reason: "Turnstile verification failed",
});
});

it("names the plugin as the reason", async () => {
vi.mocked(plugin.evaluate).mockResolvedValue(false);
expect(await evaluate(input)).toMatchObject({
allowed: false,
reason: "Rejected by verification plugin",
});
});
});
Loading
Loading