Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
121 commits
Select commit Hold shift + click to select a range
8583552
feat(svcauth): the articles:contribute scope, OIDC only, budgeted lik…
andreimarinescu Oct 13, 2026
4042c6f
feat(articles): origin, contributors and version_contributor on the r…
andreimarinescu Oct 13, 2026
fce6e79
feat(articles): the contribution PUT, its ten rules and the attributi…
andreimarinescu Oct 13, 2026
0111a3f
feat(articles): twenty kept versions for a community record, attribut…
andreimarinescu Oct 13, 2026
2adc0b7
feat(articles): status=pending and tier= list filters for articles:re…
andreimarinescu Oct 13, 2026
82e4f8a
feat(articles): POST /v1/articles/{id}/revert restores a kept version…
andreimarinescu Oct 13, 2026
a05f194
test(articles): revert body, target, environment, replay, pruning and…
andreimarinescu Oct 13, 2026
6d36adc
test(cosift): contributions, reverts and the wanted-list filters thro…
andreimarinescu Oct 13, 2026
b520b40
test(articles): the contribution PUT keeps or embeds the lead as the …
andreimarinescu Oct 14, 2026
b8c2676
feat(wiki): resolve contributor ids to handles through a bounded cache
andreimarinescu Oct 14, 2026
7a63035
feat(sharedaccount): read contributor handles and bans through a fiel…
andreimarinescu Oct 14, 2026
46d8e2f
feat(wiki): name an article's contributors and carry the CC BY-SA 4.0…
andreimarinescu Oct 14, 2026
85fe90d
feat(wiki): serve /wiki/<slug>.md, the article as safe Markdown
andreimarinescu Oct 14, 2026
969c57d
feat(wiki): serve the style guide at /wiki/style and its pinned bytes…
andreimarinescu Oct 14, 2026
2b2a7e5
feat(wiki): publish the contributor terms at /wiki/terms
andreimarinescu Oct 14, 2026
0eaef55
feat(wiki): list wanted stubs by generic title and Low articles at /w…
andreimarinescu Oct 14, 2026
c6f90dc
feat(wiki): serve /llms.txt, the promoted articles as links to their …
andreimarinescu Oct 14, 2026
0109552
deploy: route /llms.txt to the community app and cover the new wiki r…
andreimarinescu Oct 14, 2026
227fdfe
test(wiki): llms.txt golden and the injection corpus through its titles
andreimarinescu Oct 14, 2026
e33b841
feat(sharedaccount): read a contributor's handle, fetch counters and …
andreimarinescu Oct 14, 2026
3b0f220
feat(community): resolve wiki contributor handles through the shared …
andreimarinescu Oct 14, 2026
bad3973
test(community): carry the shared contribution vectors with a drift c…
andreimarinescu Oct 14, 2026
c3502d3
feat(community): GET /api/contributions, a joined contributor's outco…
andreimarinescu Oct 14, 2026
535e079
test(community): contributions limits, hint-free answers, handle and …
andreimarinescu Oct 14, 2026
7575f62
test(community): /api/contributions end to end on the Firestore emulator
andreimarinescu Oct 14, 2026
8df15f3
feat(web): My contributions view with outcome hints, earned credits a…
andreimarinescu Oct 14, 2026
17f0a8d
test(wiki): the wanted board drops an item whose id is not a ULID
andreimarinescu Oct 14, 2026
740bd53
fix(wiki): cache the style and terms pages for 5 minutes, not an hour
andreimarinescu Oct 14, 2026
e304241
feat(wiki): build the .md form from escaped text only
andreimarinescu Oct 14, 2026
3b4c6db
feat(config): the work block with ranges, and the COSIFT_WORK_ENABLED…
andreimarinescu Oct 13, 2026
89ddfe5
feat(crawler): ExtractVersion and the 64-bit simhash over word shingles
andreimarinescu Oct 13, 2026
dc7df1d
feat(crawler): an artifact's vectors are optional, and a worker's are…
andreimarinescu Oct 13, 2026
89c6aaf
feat(store): a document records whose text it holds in verified_by
andreimarinescu Oct 13, 2026
2d8ca48
refactor(crawler): split processClaimed into a fetch half and an inde…
andreimarinescu Oct 14, 2026
2cf2c45
feat(crawler): IndexAttested and FetchCopy
andreimarinescu Oct 14, 2026
9f2c5bf
feat(v1): /v1/contents reports a document's source and, for an attest…
andreimarinescu Oct 14, 2026
4794c13
feat(work): the pool, leases and claim over the W storage family
andreimarinescu Oct 14, 2026
a004093
feat(work): the errored and frontier feeders and the explicit push
andreimarinescu Oct 14, 2026
be21d74
feat(work): submissions, attestation groups and the quorum
andreimarinescu Oct 14, 2026
8bd0b57
feat(work): the sweeper, server re-fetch, moderation, settled events …
andreimarinescu Oct 14, 2026
2cd3d10
feat(work): purge a forged URL and void a worker's pending attestations
andreimarinescu Oct 14, 2026
883c458
feat(work): the stats block and the bounded-label metrics
andreimarinescu Oct 14, 2026
0d9f8fd
feat(store,crawler): scan frontier rows without claiming them, and an…
andreimarinescu Oct 14, 2026
36bc95b
feat(v1): the work routes and the engine services behind them
andreimarinescu Oct 14, 2026
88ce197
feat(admin): /admin/work-purge and /admin/work-void for the operator
andreimarinescu Oct 14, 2026
7c578bc
feat(cli): cosift work purge and cosift work void
andreimarinescu Oct 14, 2026
c64ffd5
feat(work): review fix, submissions, attestation groups and the quorum
andreimarinescu Oct 14, 2026
b86e6aa
feat(crawler): review fix, IndexAttested and FetchCopy
andreimarinescu Oct 14, 2026
3bcfe40
test: a frontier or pushed quorum indexes nothing and leaves the row …
andreimarinescu Oct 14, 2026
f7391d1
test: a dissenter is never paid as verified, one pair counts once, an…
andreimarinescu Oct 14, 2026
639b6ee
test: an attested page seeds no links on the embed-queue path either
andreimarinescu Oct 14, 2026
a1db84f
test: an errored quorum held back by a full text queue is leasable ag…
andreimarinescu Oct 14, 2026
307c79c
test(work): vendor the work_agreed_text vectors and refresh work_veri…
andreimarinescu Oct 14, 2026
bb300e4
feat(work): cut a text into content-defined parts and hash them (WORK…
andreimarinescu Oct 14, 2026
90ac1b7
feat(work): an errored quorum indexes only the text both workers saw …
andreimarinescu Oct 14, 2026
f89facb
feat(work): a late attestation on an errored page follows the same ag…
andreimarinescu Oct 14, 2026
51b974d
feat(work): void on an errored record drops the groups its worker anc…
andreimarinescu Oct 14, 2026
718cae8
test(work): an FNV-colliding part is not agreed, only anchors store p…
andreimarinescu Oct 14, 2026
6cb4204
test(v1): tampered text on an errored page never reaches /v1/contents
andreimarinescu Oct 14, 2026
b66d250
test(crawler): a worker chunk outside the indexed text drops the vect…
andreimarinescu Oct 14, 2026
ac8c85a
test(work): an errored quorum indexes the text agreed with the quorat…
andreimarinescu Oct 14, 2026
183ce8e
test(work): a late attestation is held to the anchor's length, not a …
andreimarinescu Oct 14, 2026
06b00d9
feat(work): the indexed title of an errored quorum is the normalised …
andreimarinescu Oct 14, 2026
ddcfa39
feat(work): an errored anchor's part hashes move out of W a into W p …
andreimarinescu Oct 14, 2026
4a73618
feat(work): W p is deleted with its record at the sweep, the TTL, a p…
andreimarinescu Oct 14, 2026
e34e579
feat(work): an errored quorum indexes the in-order alignment of both …
andreimarinescu Oct 14, 2026
aebef4d
test(work): the reviewers' reproductions of the membership rule canno…
andreimarinescu Oct 14, 2026
9e7eb0f
test(work): a late attestation is aligned with the verified group's a…
andreimarinescu Oct 14, 2026
a1d5059
test(work): the walk of a long text with more than 255 matches keeps …
andreimarinescu Oct 14, 2026
af50c7e
test(work): deleting a URL's W p leaves the W p of every other URL
andreimarinescu Oct 14, 2026
7fe1c73
feat(community): derive opaque worker and net ids, and add the work t…
andreimarinescu Oct 13, 2026
3d42537
feat(sharedaccount): read an account's join state for the work broker
andreimarinescu Oct 13, 2026
b9e2e07
feat(sharedaccount): add fetch counters to a contributor with a field…
andreimarinescu Oct 13, 2026
610ed67
feat(sharedaccount): the alert dedup key and the leak validator, agai…
andreimarinescu Oct 13, 2026
42d7e1b
feat(sharedaccount): raise a community alert with the dash's producer…
andreimarinescu Oct 13, 2026
8b4d401
feat(community): the join gate for work: joined, not banned, current …
andreimarinescu Oct 13, 2026
a6cb488
feat(community): buffer fetch counters per contributor and flush them…
andreimarinescu Oct 13, 2026
19f6f31
feat(community): queue the work_moderation_ceiling alert once per UTC…
andreimarinescu Oct 13, 2026
4b42bcb
feat(community): POST /api/work/claim: the join check, worker and net…
andreimarinescu Oct 13, 2026
04288b0
feat(community): POST /api/work/submit relays an attestation with the…
andreimarinescu Oct 14, 2026
2b9853c
test(community): fuzz the submit envelope: nothing the client names r…
andreimarinescu Oct 14, 2026
02f1aa4
feat(community): the settled loop pays fetch credits for new content …
andreimarinescu Oct 14, 2026
cf2acab
feat(community): prevalidate classes a blocking fetch failure and dis…
andreimarinescu Oct 14, 2026
59ea8d5
feat(community): the push loop offers blocked pages to the work pool,…
andreimarinescu Oct 14, 2026
00af9a8
feat(community): the dispatch loop takes its next job round-robin by …
andreimarinescu Oct 14, 2026
3cf5a1a
feat(community): a moderate answer with ceiling:true requeues the sub…
andreimarinescu Oct 14, 2026
efd7e4f
feat(community): Open checks what work stands on, and Run drives the …
andreimarinescu Oct 14, 2026
71278a0
feat(community): COSIFT_COMMUNITY_WORK, COSIFT_COMMUNITY_FETCH_DAILY_…
andreimarinescu Oct 14, 2026
04b5a6e
test(community): fetch credits pay once, never past the cap and never…
andreimarinescu Oct 14, 2026
c21c4f8
test(community): the work routes and loops against the Firestore emul…
andreimarinescu Oct 14, 2026
3f40fd8
test(community): pin the submit limit as 64 and each id to its own salt
andreimarinescu Oct 14, 2026
bc76efb
test(community): the engine's rate-limit wait is relayed between one …
andreimarinescu Oct 14, 2026
af33760
test(community): the meter answers within 200 ms while the settled, p…
andreimarinescu Oct 14, 2026
322d346
test(sharedaccount): a malformed contributor id and a 64-digit run ar…
andreimarinescu Oct 14, 2026
ac3e6fb
feat(community): review fix, the push loop offers blocked pages to th…
andreimarinescu Oct 14, 2026
8e325e0
feat(community): review fix, POST /api/work/submit relays an attestat…
andreimarinescu Oct 14, 2026
0dcac9c
feat(crawler): a browser-like header set and a client that re-checks …
andreimarinescu Oct 15, 2026
5b5b0b1
feat(crawler): ExtractBody and ContentHash are shared by FetchOne and…
andreimarinescu Oct 15, 2026
8209bc5
feat(cosift): -index-locally fetches through fetchAndExtract and the …
andreimarinescu Oct 15, 2026
72dd1ab
feat(cosift): the work API client sends the bearer token and the comm…
andreimarinescu Oct 15, 2026
e7cc0f4
feat(cosift): a work submission is built from the extracted page and …
andreimarinescu Oct 15, 2026
96bbf4a
feat(cosift): classify work refusals into retry, back-off, stop-with-…
andreimarinescu Oct 15, 2026
be2a5db
feat(cosift): the bounded worker loop claims, fetches and submits wit…
andreimarinescu Oct 15, 2026
e05b2a5
test(cosift): worker retry, back-off, stop and guidance rules
andreimarinescu Oct 15, 2026
c037edf
test(cosift): the worker stops on a real SIGINT
andreimarinescu Oct 15, 2026
dd5add0
feat(cosift): contribute -worker, with bearer-only authentication and…
andreimarinescu Oct 15, 2026
402f111
test(cosift): the worker runs end to end through the community app ag…
andreimarinescu Oct 15, 2026
a82c453
fix(cosift): the worker parses a PDF in a child process, as the serve…
andreimarinescu Oct 15, 2026
1e2a40c
test(cosift): take the current shared contribution vectors, pin them …
andreimarinescu Oct 16, 2026
741d9a1
fix(cosift): a clawback resolves through the uid link and never expir…
andreimarinescu Oct 16, 2026
39557af
fix(cosift): one account can use only its share of the day's content …
andreimarinescu Oct 16, 2026
465b984
test(cosift): a content check is not made when it cannot be counted
andreimarinescu Oct 16, 2026
ede5f59
docs: ENV.md lists the fetch-work and community work variables
andreimarinescu Oct 16, 2026
60f9187
docs: COMMUNITY.md covers fetch work, article contributions and the p…
andreimarinescu Oct 16, 2026
7a4732b
docs: HANDOVER.md lists the work admin endpoints and routes
andreimarinescu Oct 16, 2026
9f721d3
docs: API.md describes the work block of /stats and the work metrics
andreimarinescu Oct 16, 2026
30cfa5f
docs: AGENT-SETUP.md shows the contribute worker and the browser fetc…
andreimarinescu Oct 16, 2026
70747fd
docs: the cosift skill lists the contribute worker
andreimarinescu Oct 16, 2026
83464fc
docs: README lists the contribute worker and cosift work in the CLI r…
andreimarinescu Oct 16, 2026
f7755ff
docs: community.env.example names the work scope and the llms.txt page
andreimarinescu Oct 16, 2026
ad3c342
docs: SHARED-ACCOUNTS.md notes the work alerts need document creation
andreimarinescu Oct 16, 2026
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,12 @@ cosift contents <url...> [-server URL] [-file PATH] [-text] [-json]
hit /contents — single GET or batch POST
cosift admin <stats|config|recrawl|recrawl-domain|reembed> [-server URL] [-token TOKEN] [-json]
admin-protected operator endpoints
cosift contribute -worker [-max-pages N] [-duration D] [-concurrency C] [-server URL]
fetch pages leased by the network and submit them
(bearer token required; see docs/COMMUNITY.md)
cosift work purge --url URL | void --worker ID [-y] [-server URL] [-token TOKEN] [-json]
remove a forged page, or one worker's pending
attestations; a dry run without -y
cosift stats [-backend sqlite|pebble] doc / term counts + data dir (per-backend)
cosift crawl-status [-hosts N] [-errors N] [-target N]
live operator snapshot of an ongoing crawl: counts, frontier
Expand Down
91 changes: 91 additions & 0 deletions cmd/cosift/cmd_work.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package main

import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"

"github.com/pilot-protocol/cosift/internal/config"
)

const workUsage = `usage: cosift work purge --url URL [-y] [-server URL] [-token TOKEN] [-json]
cosift work void --worker ID [-y] [-server URL] [-token TOKEN] [-json]

purge removes what a forged URL indexed; void drops one worker's pending attestations.
Without -y nothing changes and the command prints what it would do.`

// runWork is `cosift work purge|void`: one call to a loopback /admin route of the running engine.
func runWork(ctx context.Context, cfg *config.Config, args []string, stdout, stderr io.Writer) error {
if len(args) == 0 || (args[0] != "purge" && args[0] != "void") {
return &usageError{msg: workUsage}
}
op := args[0]
fs := flag.NewFlagSet("work "+op, flag.ContinueOnError)
fs.SetOutput(io.Discard)
server := fs.String("server", strings.Replace("http://"+cfg.Server.Addr, "0.0.0.0", "127.0.0.1", 1), "cosift server URL")
token := fs.String("token", "", "admin bearer token (defaults to COSIFT_ADMIN_TOKEN)")
jsonOut := fs.Bool("json", false, "print the raw JSON answer")
apply := fs.Bool("y", false, "apply the change")
target := fs.String("url", "", "the forged URL (purge)")
worker := fs.String("worker", "", "the worker id from the app's work_workers table (void)")
if err := fs.Parse(args[1:]); err != nil || fs.NArg() > 0 {
return &usageError{msg: workUsage}
}
path, body := "/admin/work-purge", map[string]any{"url": *target, "apply": *apply}
if op == "void" {
path, body = "/admin/work-void", map[string]any{"worker": *worker, "apply": *apply}
}
if (op == "purge" && *target == "") || (op == "void" && *worker == "") {
return &usageError{msg: workUsage}
}
if *token == "" {
*token = os.Getenv("COSIFT_ADMIN_TOKEN")
}
raw, _ := json.Marshal(body)
req, err := adminRequest(ctx, http.MethodPost, *server, path, *token, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
res, err := (&http.Client{Timeout: 5 * time.Minute}).Do(req)
if err != nil {
return fmt.Errorf("post %s: %w", *server, err)
}
defer res.Body.Close()
out, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("server %d: %s", res.StatusCode, bytes.TrimSpace(out))
}
if *jsonOut {
fmt.Fprintln(stdout, strings.TrimSpace(string(out)))
return nil
}
var m map[string]any
if err := json.Unmarshal(out, &m); err != nil {
return fmt.Errorf("decode: %w", err)
}
mode := "applied"
if !*apply {
mode = "dry run, nothing changed; add -y to apply"
}
fmt.Fprintf(stdout, "%s (%s)\n", op, mode)
for _, k := range workAnswerKeys[op] {
fmt.Fprintf(stdout, " %-22s %v\n", k, m[k])
}
return nil
}

var workAnswerKeys = map[string][]string{
"purge": {"documents_deleted", "documents_kept", "records_deleted", "texts_deleted", "workers_settled", "parked_until"},
"void": {"records_changed", "attestations_dropped", "records_deleted", "urls_parked"},
}
94 changes: 94 additions & 0 deletions cmd/cosift/cmd_work_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package main

import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/pilot-protocol/cosift/internal/config"
)

type workStub struct {
path, auth, ctype string
body map[string]any
status int
answer string
}

func (s *workStub) serve(t *testing.T) string {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.path, s.auth, s.ctype = r.URL.Path, r.Header.Get("Authorization"), r.Header.Get("Content-Type")
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &s.body)
w.WriteHeader(s.status)
io.WriteString(w, s.answer)
}))
t.Cleanup(srv.Close)
return srv.URL
}

func runWorkCmd(url string, args ...string) (string, error) {
var out bytes.Buffer
err := runWork(context.Background(), &config.Config{}, append(args, "-server", url), &out, io.Discard)
return out.String(), err
}

func TestWorkPurgeCommandIsADryRunWithoutY(t *testing.T) {
t.Setenv("COSIFT_ADMIN_TOKEN", "env-token")
stub := &workStub{status: 200, answer: `{"documents_deleted":1,"documents_kept":0,"records_deleted":1,"texts_deleted":0,"workers_settled":2,"parked_until":"2026-10-27T10:00:00Z"}`}
url := stub.serve(t)
out, err := runWorkCmd(url, "purge", "-url", "https://a.example.org/x")
if err != nil {
t.Fatal(err)
}
if stub.path != "/admin/work-purge" || stub.auth != "Bearer env-token" || stub.ctype != "application/json" || stub.body["url"] != "https://a.example.org/x" || stub.body["apply"] != false || len(stub.body) != 2 {
t.Fatalf("%+v", stub)
}
if !strings.Contains(out, "dry run, nothing changed") || !strings.Contains(out, "documents_deleted") || !strings.Contains(out, "2026-10-27T10:00:00Z") {
t.Fatalf("%s", out)
}
out, err = runWorkCmd(url, "purge", "--url", "https://a.example.org/x", "-y", "-token", "flag-token")
if err != nil || stub.body["apply"] != true || stub.auth != "Bearer flag-token" || !strings.Contains(out, "purge (applied)") {
t.Fatalf("%+v %s %v", stub, out, err)
}
}

func TestWorkVoidCommand(t *testing.T) {
stub := &workStub{status: 200, answer: `{"records_changed":3,"attestations_dropped":3,"records_deleted":1,"urls_parked":2}`}
url := stub.serve(t)
worker := strings.Repeat("a", 32)
out, err := runWorkCmd(url, "void", "-worker", worker, "-y", "-json")
if err != nil || stub.path != "/admin/work-void" || stub.body["worker"] != worker || stub.body["apply"] != true {
t.Fatalf("%+v %v", stub, err)
}
if strings.TrimSpace(out) != stub.answer {
t.Fatalf("-json prints the raw answer: %s", out)
}
out, _ = runWorkCmd(url, "void", "-worker", worker)
if !strings.Contains(out, "urls_parked") || stub.body["apply"] != false {
t.Fatalf("%s", out)
}
}

func TestWorkCommandSurfacesRefusalsAndUsageErrors(t *testing.T) {
stub := &workStub{status: 401, answer: `{"error":"Unauthorized","status":401,"detail":"missing or invalid peer token"}`}
url := stub.serve(t)
if _, err := runWorkCmd(url, "purge", "-url", "https://a.example.org/x"); err == nil || !strings.Contains(err.Error(), "server 401") {
t.Fatalf("%v", err)
}
for _, args := range [][]string{{}, {"nuke"}, {"purge"}, {"void"}, {"purge", "-url", "u", "extra"}, {"void", "-bogus"}} {
var ue *usageError
if _, err := runWorkCmd(url, args...); !errors.As(err, &ue) {
t.Errorf("%v: %v", args, err)
}
}
if _, err := runWorkCmd("http://127.0.0.1:1", "purge", "-url", "u"); err == nil {
t.Fatal("an unreachable server is an error")
}
}
65 changes: 63 additions & 2 deletions cmd/cosift/community.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import (
"github.com/pilot-protocol/cosift/internal/sharedaccount"
)

// openCommunity opens the community app; tests replace it.
var openCommunity = community.Open

func runCommunity(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("community", flag.ContinueOnError)
addr := fs.String("addr", "127.0.0.1:7780", "listen address")
Expand Down Expand Up @@ -52,6 +55,14 @@ func runCommunity(ctx context.Context, args []string) error {
if err != nil {
return err
}
work, fetchCap, environment, err := communityWorkFromEnv()
if err != nil {
return err
}
moderationShare, err := communityModerationShareFromEnv()
if err != nil {
return err
}
var trusted []string
if *proxies != "" {
trusted = strings.Split(*proxies, ",")
Expand All @@ -69,7 +80,7 @@ func runCommunity(ctx context.Context, args []string) error {
defer client.Close()
provider = client
}
s, err := community.Open(community.Config{GAMeasurementID: os.Getenv("COSIFT_GA_MEASUREMENT_ID"), Shared: provider, SharedPasswordEnabled: os.Getenv("COSIFT_SHARED_PASSWORD_ENABLED") == "1", DataDir: *dir, Backend: *backend, PublicURL: *publicURL, BrokerKey: os.Getenv("COSIFT_COMMUNITY_BROKER_KEY"), EngineURL: os.Getenv("COSIFT_COMMUNITY_ENGINE_URL"), Awards: awards, TrustedProxies: trusted, GuestInterval: *guestInterval, MemberFreeRPM: *freeRPM, SearchRPM: *searchRPM, AnswerRPM: *answerRPM, ResearchPer10Min: *researchLimit, StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), AllowTestPayments: os.Getenv("COSIFT_ALLOW_TEST_PAYMENTS") == "1", ContributionReward: rewardOn, ContributionRewardDailyCap: rewardCap, StripePortalConfigurationID: os.Getenv("COSIFT_STRIPE_PORTAL_CONFIGURATION_ID"), GSCVerification: os.Getenv("COSIFT_GSC_VERIFICATION"), Wiki: wikiConfig()})
s, err := openCommunity(community.Config{GAMeasurementID: os.Getenv("COSIFT_GA_MEASUREMENT_ID"), Shared: provider, SharedPasswordEnabled: os.Getenv("COSIFT_SHARED_PASSWORD_ENABLED") == "1", DataDir: *dir, Backend: *backend, PublicURL: *publicURL, BrokerKey: os.Getenv("COSIFT_COMMUNITY_BROKER_KEY"), EngineURL: os.Getenv("COSIFT_COMMUNITY_ENGINE_URL"), Awards: awards, Work: work, FetchDailyCap: fetchCap, ModerationsPerAccount: moderationShare, Environment: environment, TrustedProxies: trusted, GuestInterval: *guestInterval, MemberFreeRPM: *freeRPM, SearchRPM: *searchRPM, AnswerRPM: *answerRPM, ResearchPer10Min: *researchLimit, StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), AllowTestPayments: os.Getenv("COSIFT_ALLOW_TEST_PAYMENTS") == "1", ContributionReward: rewardOn, ContributionRewardDailyCap: rewardCap, StripePortalConfigurationID: os.Getenv("COSIFT_STRIPE_PORTAL_CONFIGURATION_ID"), GSCVerification: os.Getenv("COSIFT_GSC_VERIFICATION"), Wiki: wikiConfig()})
if err != nil {
return err
}
Expand Down Expand Up @@ -109,6 +120,38 @@ func communityAwardsFromEnv() (bool, error) {
return false, fmt.Errorf("COSIFT_COMMUNITY_AWARDS must be 0 or 1")
}

// communityWorkFromEnv reads the work switch (0 by default), the per-account daily page cap (0 = default) and the environment label of the alerts.
func communityWorkFromEnv() (enabled bool, fetchCap int, environment string, err error) {
switch strings.TrimSpace(os.Getenv("COSIFT_COMMUNITY_WORK")) {
case "", "0":
case "1":
enabled = true
default:
return false, 0, "", fmt.Errorf("COSIFT_COMMUNITY_WORK must be 0 or 1")
}
if v := strings.TrimSpace(os.Getenv("COSIFT_COMMUNITY_FETCH_DAILY_CAP")); v != "" {
n, convErr := strconv.Atoi(v)
if convErr != nil || n < 1 || n > 10000 {
return false, 0, "", fmt.Errorf("COSIFT_COMMUNITY_FETCH_DAILY_CAP must be an integer from 1 to 10000")
}
fetchCap = n
}
return enabled, fetchCap, strings.TrimSpace(os.Getenv("COSIFT_ENV")), nil
}

// communityModerationShareFromEnv reads the content checks one account may use per UTC day (0 = default).
func communityModerationShareFromEnv() (int, error) {
v := strings.TrimSpace(os.Getenv("COSIFT_COMMUNITY_MODERATIONS_PER_ACCOUNT"))
if v == "" {
return 0, nil
}
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 10000 {
return 0, fmt.Errorf("COSIFT_COMMUNITY_MODERATIONS_PER_ACCOUNT must be an integer from 1 to 10000")
}
return n, nil
}

// communityRewardFromEnv reads the contribution-reward switch (off by default) and the per-account daily cap (0 = default).
func communityRewardFromEnv() (enabled bool, dailyCap int, err error) {
switch strings.ToLower(strings.TrimSpace(os.Getenv("COSIFT_COMMUNITY_CONTRIBUTION_REWARD"))) {
Expand Down Expand Up @@ -146,14 +189,25 @@ func runContributeConfigured(ctx context.Context, cfg *config.Config, args []str
login := fs.Bool("login", false, "save an authenticated CLI session")
logout := fs.Bool("logout", false, "revoke and delete the saved CLI session")
guest := fs.Bool("guest", false, "perform Search, Answer or Research without login; contributions require login")
workerMode := fs.Bool("worker", false, "fetch blocked pages leased by the network and submit them (needs your bearer token)")
maxPages := fs.Int("max-pages", 100, "worker: stop after claiming this many pages (0 = no page limit)")
duration := fs.Duration("duration", 30*time.Minute, "worker: stop claiming after this long (0 = no time limit)")
concurrency := fs.Int("concurrency", 4, "worker: leases in flight at once, 1 to 32")
if err := fs.Parse(args); err != nil {
return err
}
var explicitServer, explicitSession bool
var explicitServer, explicitSession, workerFlag bool
fs.Visit(func(f *flag.Flag) {
explicitServer = explicitServer || f.Name == "server"
explicitSession = explicitSession || f.Name == "session-file"
workerFlag = workerFlag || f.Name == "max-pages" || f.Name == "duration" || f.Name == "concurrency"
})
if workerFlag && !*workerMode {
return fmt.Errorf("-max-pages, -duration and -concurrency require -worker")
}
if *workerMode && (*login || *logout || *guest || *requestMode || *local || *credits || *file != "" || *query != "" || fs.NArg() > 0) {
return fmt.Errorf("-worker cannot be combined with other operations")
}
// The installer can provision this one well-known session. Explicit
// credentials and guest mode always win; no harness config is inspected.
if !*guest && !*login && !explicitSession && *sessionFile == "" && os.Getenv("COSIFT_TOKEN") == "" && *email == "" && os.Getenv("COSIFT_PASSWORD") == "" {
Expand Down Expand Up @@ -188,6 +242,13 @@ func runContributeConfigured(ctx context.Context, cfg *config.Config, args []str
if token != "" && *sessionFile != "" && !*login {
return fmt.Errorf("choose COSIFT_TOKEN or a saved session, not both")
}
if *workerMode {
bearer, err := workerBearer(token, *sessionFile, strings.TrimRight(*server, "/"), password != "" || *email != "")
if err != nil {
return err
}
return runWorkerMode(ctx, strings.TrimRight(*server, "/"), bearer, workerOptions{MaxPages: *maxPages, Duration: *duration, Concurrency: *concurrency}, os.Stdout, os.Stderr)
}
if token == "" && !*guest && (*login || *sessionFile == "") && ((*email == "") != (password == "")) {
return fmt.Errorf("set both COSIFT_EMAIL and COSIFT_PASSWORD, or use -guest for retrieval")
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/cosift/community_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
)

func indexLocalContributions(ctx context.Context, cfg *config.Config, urls []string) ([]*crawler.LocalArtifact, error) {
return indexLocalContributionsWithClient(ctx, cfg, urls, crawler.PublicHTTPClient(30*time.Second))
return indexLocalContributionsWithClient(ctx, cfg, urls, crawler.BrowserClient(30*time.Second))
}

func indexLocalContributionsWithClient(ctx context.Context, cfg *config.Config, urls []string, client *http.Client) ([]*crawler.LocalArtifact, error) {
Expand Down Expand Up @@ -51,7 +51,7 @@ func indexLocalContributionsWithClient(ctx context.Context, cfg *config.Config,
case <-timer.C:
}
}
page, err := crawler.FetchOne(ctx, client, "Cosift-Community/1.0", raw, 2<<20)
page, err := fetchAndExtract(ctx, client, crawler.BrowserHeaders(), raw, 2<<20)
if err != nil {
return nil, err
}
Expand Down
39 changes: 39 additions & 0 deletions cmd/cosift/community_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"

"github.com/pilot-protocol/cosift/internal/config"
"github.com/pilot-protocol/cosift/internal/crawler"
"github.com/pilot-protocol/cosift/internal/store"
)

Expand Down Expand Up @@ -55,3 +56,41 @@ func TestLocalIndexPersistsTextAndEmbeddings(t *testing.T) {
t.Fatal("local document was not indexed")
}
}

func TestLocalIndexFetchesThroughTheSharedBrowserFetch(t *testing.T) {
emb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Input []string `json:"input"`
}
json.NewDecoder(r.Body).Decode(&body)
data := []any{}
for i := range body.Input {
data = append(data, map[string]any{"index": i, "embedding": []float32{1, 2}})
}
json.NewEncoder(w).Encode(map[string]any{"data": data})
}))
defer emb.Close()
cfg := &config.Config{DataDir: t.TempDir(), Embeddings: config.Embeddings{URL: emb.URL, Model: "test", Dim: 2}}
var pageUA string
client := &http.Client{Transport: localPageTransport(func(r *http.Request) (*http.Response, error) {
status, body := 200, `<html><title>Guide</title><article>`+strings.Repeat("This is useful public scientific documentation. ", 5)+`</article></html>`
switch r.URL.Path {
case "/robots.txt":
body = "User-agent: *\nAllow: /"
case "/gone":
status = 404
default:
pageUA = r.Header.Get("User-Agent")
}
return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{"text/html"}}, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil
})}
if _, err := indexLocalContributionsWithClient(context.Background(), cfg, []string{"https://example.com/guide"}, client); err != nil {
t.Fatal(err)
}
if pageUA != crawler.BrowserUserAgent {
t.Fatalf("the page was fetched as %q", pageUA)
}
if _, err := indexLocalContributionsWithClient(context.Background(), cfg, []string{"https://example.com/gone"}, client); err == nil || !strings.Contains(err.Error(), "404") {
t.Fatalf("a 404 page was indexed: %v", err)
}
}
Loading
Loading