Skip to content

Branch Update - #276

Merged
xbabyx merged 49 commits into
testingfrom
develop
Sep 7, 2026
Merged

Branch Update#276
xbabyx merged 49 commits into
testingfrom
develop

Conversation

@xbabyx

@xbabyx xbabyx commented Sep 7, 2026

Copy link
Copy Markdown
Member

No description provided.

xbabyx and others added 30 commits July 10, 2026 15:45
…pport

npm CLI bundled with Node 18 doesn't support OIDC trusted publishing,
causing publish to fail with 404 despite trusted publisher being
configured correctly on npmjs.com.
getStaticProps in [name].tsx threw an uncaught error when the
data/{name}.json file didn't exist, causing a blank page for any
invalid route. Now falls back to notFound so the existing 404 page
is rendered.
PUT /api/bounties/:id silently dropped reward_type and tier_count,
so a bounty's tiered payout structure could only be set at creation
time. The edit form also never exposed these fields, so sponsors had
no way to convert a bounty to tiered (or adjust winner count) after
creation.
The sponsor landing page only redirected to the dashboard when the
user already owned a sponsor record, ignoring the is_god flag
returned by /api/sponsors/user/:userId. God users without their own
sponsor record were shown the "Post for Free" marketing page instead
of the dashboard's god-mode sponsor picker.
…safe submission URLs

Most write endpoints for bounties, submissions, sponsors, comments, and
bookmarks only validated that a resource existed, not that the requester
owned it — identity was taken from client-supplied user_id fields instead
of the session. Added a shared getSessionUserId()/isGodUser() helper and
applied ownership checks across all of them. Also rejects javascript:/data:
submission URLs (isValidUrl now enforces http/https only), blocks
submissions to closed bounties, and validates comment parent_comment_id
belongs to the same bounty.

Added notificationService tests and a regression test for the URL scheme
fix; vitest related --run is now wired into lint-staged for bounty files.
Admin analytics/user-management routes had no auth check at all,
letting anyone read user emails, wallet addresses, session IPs, and
email logs, or ban/unban users and verify sponsors unauthenticated.
… push

Moves all *.test.ts into a single tests/ tree (mirroring src/) using
the existing @/ path alias, adds tests/worker/admin-auth.test.ts
covering the /api/admin/* auth guard, and wires a pre-push hook to
run the suite on every push. The hook skips the suite when a push
only touches dApp content (data/*.json, public/dapps/**), since
those are pure content submissions with no code to test.
updated preview image for alphscoundrel
bounties.reward_amount held two different units depending on
reward_currency: ALPH for 'ALPH' rows, but a USD figure for 'USD' rows.
Nothing could aggregate across both -- /api/bounty-overview summed two
disjoint piles, where an ALPH bounty contributed nothing to the USD
total and vice versa, so neither number described the whole programme.
Reading a reward also meant first checking the currency to learn what
unit the number in front of you was in.

reward_amount is now always ALPH. Which side the sponsor fixes their
promise on -- and therefore who carries the price risk -- moves to a
separate `denomination` column:

  denomination='alph' -> reward_amount is authoritative, reward_usd derived
  denomination='usd'  -> target_usd    is authoritative, reward_amount derived

reward_usd is comparable across both and safe to SUM.

The rate comes from CoinGecko once a day at 08:00 Europe/Berlin. Cloudflare
crons are UTC-only and 08:00 Berlin is 07:00 UTC in winter but 06:00 in
summer, so rather than schedule a fixed hour the existing hourly cron asks
Intl for the Berlin wall-clock time. A failed 08:00 run retries at 09:00
rather than losing the day. Only open bounties are revalued, so settled
figures stay reproducible instead of drifting with the market.

Form-side, the hand-typed "USD Reference" field is gone -- it was the
source of every unverifiable USD figure in the table (4 of 5 rows held 0).
USD is now derived server-side only; the client never decides what a
bounty is worth. The three mixed radios (Fixed USD / Fixed Token ALPH /
Tiered USD) split into two orthogonal choices, which also makes an
ALPH-denominated tiered bounty expressible for the first time.

resolveRewardInput keeps reading the pre-025 {reward_currency:'USD'}
payload as USD-denominated. Without it a $100 bounty from a not-yet-
redeployed client would be stored as 100 ALPH, roughly 25x short. Drop
that branch once every client sends `denomination`.

Also includes the M4-M14 fixes parked from the previous session
(handlers.ts sponsor field updates, 409 on duplicate submission,
SubmissionStatus union, and the index.ts validation hunks). They were
deployed to production alongside this work, so leaving them uncommitted
would put the worker ahead of the repo.

Migration 025 and the changelog live outside the repo -- .gitignore
excludes migrations/ and *.md.
/bounty/sponsor/:slug resolves through GET /api/sponsors/name/:slug:

  SELECT * FROM sponsors
   WHERE username = ?
      OR LOWER(REPLACE(REPLACE(REPLACE(name,' ',''),'-',''),'_','')) = LOWER(...)
   LIMIT 1

Neither column was unique -- idx_sponsors_username is a plain index -- so
one slug could match two rows and LIMIT 1 returned whichever the planner
reached first. Once PUT /api/sponsors/:id began accepting `username`, any
sponsor could set theirs to a rival's name and make that rival's profile
URL resolve non-deterministically to their own page. Nothing was broken
yet only because every existing row has username NULL, so lookups all fell
through to the name branch.

Usernames are now normalised on write. That matters beyond tidiness: the
query compares `username` exactly but `name` after stripping punctuation,
so storing "Alephium" would leave /bounty/sponsor/alephium unable to match
the username branch at all.

Collision checks compare a candidate against other sponsors' usernames and
their normalised names. Checking usernames alone would not close the hole,
since the lookup ORs across both columns. Create and update return 409;
input that normalises to nothing returns 400.

GET /api/sponsors/check-username lets the form report this before submit
rather than through a 409, mirroring earn's check-slug. It is registered
ahead of GET /api/sponsors/:id, which would otherwise swallow
"check-username" as an id -- as it did on the first attempt.

Migration 026 adds a partial unique index as a backstop for the race
between check and write. It cannot cover the username-vs-name half of the
rule, which spans two columns under a normalisation SQLite cannot express
in a UNIQUE constraint; that half is enforced in the application only.

sponsors.name is still unconstrained. It feeds the same OR match, and earn
marks its equivalent @unique, but adding that here would restrict renaming
for the six existing sponsors, so it is left as a decision to make.
…umns

/bounty/sponsor/:slug resolved with

  WHERE username = ?  OR  normalize(name) = normalize(?)  LIMIT 1

and that shape, not a missing constraint, was the actual problem. Uniqueness
spanning two columns under a runtime normalisation is not expressible as a
SQLite constraint, so 026 could only cover the username half and the rest sat
in application code with a race between the check and the write. It also made
`name` load-bearing for routing, which made deterministic lookup look like it
required forbidding renames.

It hid a third bug nobody had triggered: links are built at render time as
sponsorSlug(name) (validators.ts:7), so renaming a sponsor breaks every link
to their profile.

A single stored, pre-normalised `slug` collapses all three. Lookup is now a
one-column exact match against a UNIQUE index, so the database guarantees
uniqueness and the application check is only there to fail early with a
friendlier message. `name` becomes display text with no constraint, and two
organisations with the same name can coexist under different slugs. The slug
is left alone when a sponsor is renamed, which is the point of storing it --
only an explicit `slug` in the body changes it.

Migration 027 backfills per row with values computed by the real function
rather than a REPLACE() chain. sponsorSlug() strips every non-alphanumeric
character via regex; a REPLACE chain only removes the characters it lists, so
the two diverge on any name containing something else and the resulting link
would 404. The six backfilled values equal what the client produces today, so
existing URLs keep resolving -- verified against production after applying.

check-username is renamed check-slug, matching earn and what it now checks.
Nothing consumed it yet.

sponsor_slug is returned by the bounty list, detail and bookmark queries and
used by the three link sites, each falling back to sponsorSlug(name) for
responses predating the column.
The on-chain checks confirm a transaction is real, confirmed and large enough,
but not that it has already been spent on someone else.

Migration 028 adds a partial unique index on transaction_hash, excluding '' as
well as NULL since the column is written as '' when no hash is given.
The review endpoint turns the constraint violation into a 409 with a readable
message instead of a 500.
POST /api/notifications took an arbitrary user_id, title, message and link from
any caller with no authentication, so anyone could send a "claim your reward"
notice pointing anywhere. It is now god-only.

Notifications are written by the worker in the same handler as the state change
they describe, so they survive the tab closing. That also covers the two cases
the browser path never handled: changes-requested reviews and sponsor status
changes other than verify.

Mutes are now checked for every bounty-scoped notification rather than only new
top-level comments; review verdicts deliberately bypass them.

Deletes the frontend notificationService and its six call sites.
Reviews wrote "Tier 2 placement. Reward: 305 ALPH (for 18 USD bounty)" into
reviewer_notes, and two places read it back with regexes -- the placement badge
and a profile's lifetime earnings. Rewording a note, or writing one containing
"Reward: 100 ALPH", changed someone's earnings total with nothing to catch it.

Migration 029 adds is_winner, winner_position, reward_amount, reward_currency,
reward_usd, is_paid, paid_at and label, and backfills the seven approved rows
from values parsed and checked by hand. reviewer_notes is kept as the audit
trail.

is_paid is separate from the verdict so winners can be picked before payment,
and rejecting now clears the payout columns so a stale form cannot leave one
behind. The earnings query is a SUM and the review modal reads the columns.
YYBer and others added 19 commits July 27, 2026 21:57
The hash was written regardless of status. Combined with the unique index from
028, rejecting with a stale hash in the form consumed that transaction, and the
actual winner could then never be approved with it -- they got "already been
used" with no way to see why.

Production holds hashes only on approved rows, so no cleanup was needed.
Both interfaces declared submitted_at, completed_at, review_started_at, title
and tweet_url, none of which any endpoint sends. TypeScript cannot see that, so
each was a silent undefined: the sponsor dashboard rendered a blank review date
for every submission (M1) and the literal word "Submission" as every title.

Fields now use the real column names, so the rename table in viewSubmission
goes away. submissionTitle() reads the title from the description, where
SubmissionModal actually puts it.

Adds a drift test: a declared field must be a real column or a registered join
alias, and each alias must still appear in a worker query.
The guard read the column list from migrations/000_baseline_schema.sql, which
is gitignored, so a fresh checkout crashed the file at import and CI went red.
The list is inlined instead.

That leaves a second copy of the schema, so a further test re-derives the
columns from the dump whenever one is present and fails if the two disagree.
It reports as skipped rather than passed without a dump, since a silent pass
would leave the inlined list unchecked.

Also fixes the parser: SQLite appends ALTER TABLE columns onto one line at the
end of the CREATE statement, so splitting on newlines missed all eight added by
029.
SubmissionReviewModal and SubmissionsSection each had their own extractTitle,
and aligning the sponsor dashboard added a third. The two existing copies were
the better ones -- they fall back to the truncated first line rather than
straight to a placeholder -- so the shared version keeps that behaviour.
Workers' fetch() sends no User-Agent, which CoinGecko answers with 403; once
that was fixed it returned 429, because the keyless tier limits per IP and the
Worker's egress IP is shared. Try CoinPaprika, then CoinGecko, then MEXC, and
record which one answered. Failed responses now carry the upstream's body, so
a policy rejection no longer looks like an IP block.
email_logs gains user_id and bounty_id, which alreadySent() uses so the
upcoming deadline cron cannot resend the same reminder every hour. Opt-outs
are stored per category as unsubscribe rows, so the default stays subscribed
and no backfill is needed. The check runs only inside sendAndLog(), a lookup
error still sends, and suppressed mail is logged so "I never got it" is
answerable. Unsubscribe links are HMAC-signed over (userId, category).
status was one five-value enum, so "deadline passed but review is unfinished"
had no representation and the UI inferred it from end_date. Storage now keeps
is_published and is_winners_announced as orthogonal bits, and one dependency-
free getBountyDisplayStatus() derives the label for both the worker and the
client. Creating a bounty can save a draft, which needs only a title; the full
requirements are enforced at publish time by the same check on both paths.
Publish and announce are conditional UPDATEs returning 409, so a double click
cannot re-stamp either timestamp.
Titles get a hyphenated slug capped at 60 characters on a word boundary, not
sponsorSlug(), which deletes the gaps and would turn a sentence into
"createayoutubetutorialhowto" — unreadable, and the opposite of the search
ranking that is the only reason to slug a bounty. The resolver accepts a slug
or a uuid, so every link already shared keeps working. A partial unique index
guarantees uniqueness, since drafts carry NULL.
GET /api/sponsors/:id/dashboard had no authorization, so anyone with a sponsor
id could read every submission including user_wallet_address; the H1-H11 pass
only covered write endpoints. The owner now gets the full payload and everyone
else gets what the public profile page actually reads, which keeps that page
working. The dashboard drops its local status helper for the shared
derivation, so drafts are visible at all, and offers a Publish button on them.
An hourly cron nudges bookmarkers 48h before a deadline, reminds the sponsor
7 days after one passes with submissions unreviewed, and escalates to admins
at 14 days. Every reminder is deduplicated through alreadySent(), since the
trigger fires hourly and the schedule alone guarantees nothing. The 14-day
step notifies admins rather than flagging the sponsor, because a per-sponsor
risk flag is the rejected isCaution column. Publishing now re-checks sponsor
standing, and submitting requires a username and wallet address.
ban, unban and unverify sent an in-app notice but no email, while verify sent
both from a separate call site — so the two channels disagreed about which
changes are worth telling someone, and a suspension was the one people were
never told about. All four now go through notifySponsorAccountChange() called
from the same place as the in-app notice. Uncategorised, so an opt-out cannot
suppress an account-state fact.
src/worker/types.ts and src/types/database.ts had no importers and still
described pre-migration field names, so they were the last place M1's phantom
columns survived. tweet_url reached neither the database nor any caller.
bounties.completed_at and bounty_comments.like_count are dropped in 033 —
like_count was written on every like and never read, since responses derive
the count from liked_by. assigned_to cannot be dropped while a foreign key
names it, so only its index goes. The god-only notification endpoint now
routes through notify() instead of inserting directly, which is what made it
skip notification_mutes.
…t branches

The god-mode sponsor picker now reads slug, the only column that identifies a
sponsor; username was NULL for every row, so filtering by it never matched.
resolveRewardInput drops its reward_currency='USD' shim now that every write
path sends denomination.
The sponsor dashboard total switches to reward_usd -- the branch it replaces
summed ALPH counts as dollars, overstating the figure ~25x.
Migration 034 drops sponsors.username, its two indexes and bounty_overview.
…p-ranking-1787611728478

Add dApp: Alephium Market Cap Ranking
@vercel

vercel Bot commented Sep 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
alphland Building Building Preview Sep 7, 2026 5:51am UTC

Request Review

@xbabyx
xbabyx merged commit 91f3e01 into testing Sep 7, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants