Conversation
AlphLand July 2026 Audit
Projects July 2026 Audit
…704686279 Add dApp: AlphScoundrel
…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.
…0963 Add dApp: ALPHtris
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.
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.