Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
119 commits
Select commit Hold shift + click to select a range
6eb5aa0
fix(markup): keep autolink and emphasis out of emitted link markup
maegminhui-tech Sep 24, 2026
1482cd6
fix(markup): keep autolink and emphasis out of emitted link markup
maegminhui-tech Sep 24, 2026
fe5f25f
test: locate website binding after partial org updates (#215)
RowletCC Sep 24, 2026
65f945e
fix(watches): handle concurrent duplicate saves (#212)
RowletCC Sep 24, 2026
97fce05
Make bulk operator assignments atomic (#213)
RowletCC Sep 24, 2026
69be531
fix(inbox): open long threads at recent messages (#217)
RowletCC Sep 24, 2026
cd4d111
fix(tracker): reject invalid calendar dates (#218)
RowletCC Sep 24, 2026
2fa3b77
fix(recommendations): keep rewrite signature selected (#219)
RowletCC Sep 24, 2026
ff3845f
fix(resumes): cap collision public slugs at 60 characters (#220)
RowletCC Sep 24, 2026
2a3faed
fix(jobs): return numeric search total from bigint count (#221)
RowletCC Sep 24, 2026
39df7e0
fix(import): find ZIP directory past signature-like comments (#222)
RowletCC Sep 24, 2026
d3754b3
fix(pay): reject invalid supplied amounts (#223)
RowletCC Sep 24, 2026
fe534a4
fix(updates): serialize concurrent author posts (#224)
RowletCC Sep 24, 2026
ba636a4
fix(agents): detect operator cycles across full account depth (#225)
RowletCC Sep 24, 2026
861f599
fix(browse): keep semantic resume headers (#226)
RowletCC Sep 24, 2026
44a8b42
fix(resume): parse angle-bracketed contact links (#227)
RowletCC Sep 24, 2026
5b6dbb8
fix(push): fit notifications within service size limit (#228)
RowletCC Sep 24, 2026
c336fdc
fix(rankings): include older reads in all-time popular board (#229)
RowletCC Sep 24, 2026
6be04ec
fix(import): preserve comparison text in job descriptions (#230)
RowletCC Sep 24, 2026
bc8216e
fix(capacity): alert on older incomplete public resumes (#231)
RowletCC Sep 24, 2026
307ff89
fix(openprofile): preserve mailto operator contact details (#232)
RowletCC Sep 24, 2026
b5ef338
fix(post): include employer when drafting a job (#233)
RowletCC Sep 24, 2026
3d2b831
fix(notifications): keep displayed read set complete (#234)
RowletCC Sep 24, 2026
e63ad2f
fix(feed): preserve filtered JSON feed URL (#235)
RowletCC Sep 24, 2026
0842b6b
fix(mcp): forward plural search tags filter (#236)
RowletCC Sep 24, 2026
a141318
fix(applications): save candidate metadata atomically (#237)
RowletCC Sep 24, 2026
121e859
fix(inbox): reject ambiguous message targets (#238)
RowletCC Sep 24, 2026
b83bd3b
fix(directory): count each topic instance once (#239)
RowletCC Sep 24, 2026
d647095
fix(feed): ignore view controls when filtering site RSS (#240)
RowletCC Sep 24, 2026
8bd6eec
fix(resumes): retry public slug collisions (#241)
RowletCC Sep 24, 2026
3a8a5de
fix(import): preserve HTML email contact targets (#243)
RowletCC Sep 24, 2026
bce2d18
fix(mcp): report failed notification mark-read (#244)
RowletCC Sep 24, 2026
f43fdd8
fix(directory): treat search wildcards literally (#242)
RowletCC Sep 24, 2026
b86705c
fix(applications): preserve prototype-named answers (#245)
RowletCC Sep 24, 2026
7f62a86
fix(agents): reject invalid profile URLs (#246)
RowletCC Sep 24, 2026
4c0b95b
fix(resumes): strip numbered skill list markers (#247)
RowletCC Sep 24, 2026
58d3524
fix(agents): enforce registration cap atomically (#248)
RowletCC Sep 24, 2026
f35ff75
fix(applications): paginate employer inbox (#249)
RowletCC Sep 24, 2026
83a4762
fix(openprofile): export plus-bullet accounts (#250)
RowletCC Sep 24, 2026
dd2107f
fix(agents): serialize operator graph updates (#251)
RowletCC Sep 24, 2026
fc3f792
fix(applications): preserve select option labels (#252)
RowletCC Sep 24, 2026
34e67df
fix(directory): strip emphasis from resume names (#253)
RowletCC Sep 24, 2026
54739fa
fix(resumes): invalidate stale uploaded downloads (#254)
RowletCC Sep 24, 2026
184f8fb
fix(apply): show agent disclosure validation error (#255)
RowletCC Sep 24, 2026
200abb5
fix(tui): include draft jobs in own listings (#256)
RowletCC Sep 24, 2026
2a2a8b9
fix(candidates): avoid repeating resume header (#257)
RowletCC Sep 24, 2026
d03e6a2
fix(apply): keep drafts from sending to closed jobs (#258)
RowletCC Sep 24, 2026
23b9468
fix(directory): redact phone numbers from profile summaries (#259)
RowletCC Sep 24, 2026
c3e63e5
fix(cli): return updated notifications after marking read (#260)
RowletCC Sep 24, 2026
15bf687
fix(jobs): preserve omitted settings in patches (#261)
RowletCC Sep 24, 2026
5ca4289
fix(import): detect mislabeled Word resumes (#262)
RowletCC Sep 24, 2026
cc20958
fix(search): require every requested job tag (#263)
RowletCC Sep 24, 2026
1d279cd
fix(discovery): omit inactive jobs from sitemaps (#264)
RowletCC Sep 24, 2026
441607d
fix(inbox): reuse threads for concurrent first messages (#265)
RowletCC Sep 24, 2026
7b35e97
test: fix idempotency suite skip guard for empty Postgres error messa…
kepler-ops-maker Sep 25, 2026
93d0258
Tracker: show zero and name what is missing instead of "Unknown" (#269)
ralyodio Sep 25, 2026
67166a1
0.21.1 (#270)
ralyodio Sep 25, 2026
4b4bdce
New logo, favicon and the w3bs.org look (#271)
ralyodio Sep 25, 2026
9ab022d
0.22.0 (#272)
ralyodio Sep 25, 2026
3bdc188
0.22.1: job titles at the serif headline weight (#273)
ralyodio Sep 25, 2026
e2c65ba
0.22.1 (#274)
ralyodio Sep 25, 2026
919e350
fix(resume): keep undated role subtitles out of date fields (#275)
hzhuangqiu Sep 25, 2026
ea2d637
0.22.2: header logo at 3rem (#276)
ralyodio Sep 25, 2026
2954adc
ops: deploy agenticjobs.work to dev2 on merge (#277)
ralyodio Sep 25, 2026
e571e32
db: keep the URL's sslmode from overriding the pool's own SSL choice …
ralyodio Sep 25, 2026
bf7007a
db: parse timestamptz offsets that carry seconds (LMT timezones) A t…
kepler-ops-maker Sep 26, 2026
5d2497a
updates: refuse a link to a board page that does not exist (#280)
ralyodio Sep 26, 2026
2e7149c
Reject array values in structured pay amounts (#281)
zhonglingzlzl-lgtm Sep 27, 2026
e6faf9c
fix(watches): serialize account limit checks (#282)
waytzhang Sep 28, 2026
6753fb4
Run the board on Bun in the image (#283)
ralyodio Oct 1, 2026
5f0438d
0.22.3 (#284)
ralyodio Oct 1, 2026
8db30ac
feat: Profullstack webring links in the footer (#287)
ralyodio Oct 4, 2026
9f0d41a
0.22.4 (#288)
ralyodio Oct 4, 2026
6dea4fc
Fix uppercase pay-period words being parsed as currencies (#289)
k1mtruth Oct 4, 2026
b49e0bd
fix(sync): persist metadata filled into existing boards (#290)
sun2000feng-maker Oct 4, 2026
ce57c26
fix(client): preserve the fanout timeout budget (#291)
ilanvkn Oct 6, 2026
c2a1e24
Add GET /api/health for the status page (#292)
ralyodio Oct 6, 2026
8b3b718
fix(client): preserve defaults for optional network search filters (#…
ilanvkn Oct 7, 2026
10a8215
fix(client): do not retry writes protected only by a blank key (#294)
ilanvkn Oct 7, 2026
ec3e821
fix(markdown): split lists when marker style changes (#295)
bmdy5 Oct 7, 2026
a714758
fix(cli): preserve candidate slug in news filters (#296)
OnePaperclipLab Oct 9, 2026
71fed47
fix(search): deduplicate tags before applying query cap (#298)
ayush-agrawal001 Oct 9, 2026
d363d29
fix(cli): strip closing Markdown hashes from derived job titles (#299)
ayush-agrawal001 Oct 9, 2026
73779bc
fix(cli): accept stdin dash as an option value (rebased #297) (#300)
ralyodio Oct 9, 2026
48dff27
fix: withhold contact URIs from anonymous resume views (#302)
reacx-research Oct 9, 2026
1f2cb87
test(client): give the fanout timeout test a budget CI can meet (#303)
ralyodio Oct 9, 2026
b95e56f
fix(cli): infer job titles from indented Markdown headings (#301)
OnePaperclipLab Oct 9, 2026
efd7096
fix(mcp): suppress responses to notifications (#304)
Dayire Oct 10, 2026
10f13f2
fix(markdown): preserve literal link destinations (#305)
Dayire Oct 10, 2026
c96e968
fix(resume): retain indented headings in candidate discovery (#306)
Dayire Oct 10, 2026
f84cecc
feat(webring): Random link in the Profullstack ring footer (#307)
ralyodio Oct 10, 2026
191206c
feat(footer): use @profullstack/footer (#309)
ralyodio Oct 10, 2026
52b2ede
fix(markdown): preserve blank-separated list siblings (#308)
Dayire Oct 10, 2026
e709828
fix(markdown): strip complete link destinations from summaries (#310)
Dayire Oct 10, 2026
d3afa24
fix(footer): drop the skills row's own rule above the shared footer (…
ralyodio Oct 10, 2026
b67b6cf
fix(query): drop NUL bytes in string filters instead of answering 500…
kaminariouji Oct 11, 2026
17da3d8
fix(core): drop NUL bytes where slugs and ids reach SQL, guard the de…
kaminariouji Oct 11, 2026
9acf977
fix(resume): preserve contact links with trailing destination whitesp…
shkim3142-ops Oct 12, 2026
6f3ff97
fix(docs): OpenCreds and ASDLC links point at logicsrc.com (#315)
ralyodio Oct 17, 2026
c10f86b
Add a CrawlProof ad unit above the footer on public pages (#316)
ralyodio Oct 19, 2026
0646e5c
0.22.5: draft writer falls back to OpenAI, and says when an account i…
ralyodio Oct 19, 2026
0f88c7f
0.22.6: pit.world global footer; the site footer is two rows (#318)
ralyodio Oct 19, 2026
30191e9
0.22.7: restore the @profullstack/footer bar under the site footer (#…
ralyodio Oct 19, 2026
f029bc1
chore: exempt all @profullstack/* from minimumReleaseAge (#320)
ralyodio Oct 19, 2026
d71d746
No ads on the homepage; low-profile text-link ads elsewhere (#321)
ralyodio Oct 21, 2026
6e9897d
feat(invoices): send payer, payee and property to CoinPay; read the n…
ralyodio Oct 21, 2026
38e42ab
feat(crawl): sell training crawlers a day pass on /crawl (x402-gatewa…
ralyodio Oct 21, 2026
bd9bc9c
feat(profile): shared signup profile fields via @profullstack/profile…
ralyodio Oct 26, 2026
06f33f5
chore(deps): upgrade all dependencies (0.22.8) (#325)
ralyodio Oct 26, 2026
8de51a0
feat(credits): credit codes, invites and ?ref= links (0.23.0) (#326)
ralyodio Oct 26, 2026
32b7ae0
chore(deps): @profullstack/x402-gateway ^0.15.1, never charge sign-in…
ralyodio Oct 28, 2026
aea1e11
chore(deps): @profullstack/x402-gateway ^0.15.2, old browsers get the…
ralyodio Oct 28, 2026
4e7bc4a
fix(coinpay): keep payees' wallet addresses, never their CoinPay toke…
sh1pt-actions-fleet[bot] Oct 28, 2026
fd7fa64
chore: @profullstack/x402-gateway 0.15.3 (browsers asking for a page …
sh1pt-actions-fleet[bot] Oct 28, 2026
03a37f3
chore: @profullstack/x402-gateway 0.16.0 (one fetch reads the page) (…
sh1pt-actions-fleet[bot] Oct 28, 2026
e593212
fix(apply): reject a malformed resume id before it reaches Postgres (…
maegminhui-tech Oct 29, 2026
6071c3c
fix(jobs): write only the fields a listing patch sent (#216)
maegminhui-tech Oct 29, 2026
bb7f5b9
fix(markup): port onto master's protectHtml; keep bare URLs off the s…
ralyodio Oct 29, 2026
8950fc1
Merge branch with master port (ours)
ralyodio Oct 29, 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
40 changes: 39 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,35 @@ jobs:
grep -q "no database" output.txt && { echo "::error::the API suite skipped itself"; exit 1; }
exit 0

# Production runs dist/ under Bun (see the Dockerfile), so the suite
# runs under Bun too, against a fresh database (several api tests
# depend on the seed). Node stays the gate above because the npm
# package targets Node; this step catches the places the two runtimes
# differ (callback error values, child processes started with
# process.execPath). Each file gets its own process, as under node
# --test. cli-sync.test.ts is left out: it mocks an import with
# node:module registerHooks, which Bun does not implement.
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.0

- name: Test under Bun
shell: bash
run: |
PGPASSWORD=agenticjobs psql -h localhost -U agenticjobs -d postgres \
-c 'drop database agenticjobs with (force)' -c 'create database agenticjobs'
files=$(grep -L registerHooks test/*.test.ts)
failed=""
for f in $files; do
bun test "./$f" > bun-out.txt 2>&1 || { cat bun-out.txt; failed="$failed $f"; }
if grep -q "no database" bun-out.txt; then echo "::error::$f skipped its database tests"; failed="$failed $f"; fi
cat bun-out.txt >> bun-all.txt
done
# A tally, so a green step that ran nothing is visible as one.
sed 's/\x1b\[[0-9;]*m//g' bun-all.txt | grep -E '^ *[0-9]+ (pass|fail|skip)$' \
| awk '{ n[$2] += $1 } END { printf "bun test: %d pass, %d fail, %d skip\n", n["pass"], n["fail"], n["skip"] }'
if [ -n "$failed" ]; then echo "::error::failed under Bun:$failed"; exit 1; fi

- name: The docs the board serves must exist
run: |
test -f docs/openjob.md
Expand Down Expand Up @@ -93,4 +122,13 @@ jobs:
curl -fsS http://localhost:8787/.well-known/agenticjobs
# pdftotext is what makes PDF resumes work; the image is wrong
# without it and nothing else would notice.
docker exec app sh -c 'command -v pdftotext'
docker exec app sh -c 'command -v pdftotext && command -v pandoc && command -v weasyprint'
# The board runs on Bun, as uid 1000, and there is no node to fall
# back to.
docker top app -eo pid,user,args | tee top.txt
grep -q 'bun dist/cli/index.js serve' top.txt
if docker exec app sh -c 'command -v node'; then echo '::error::node is in the image'; exit 1; fi
# PID 1 must answer SIGTERM, or every deploy waits ten seconds.
start=$(date +%s); docker stop -t 10 app; end=$(date +%s)
echo "docker stop took $((end - start))s"
test $((end - start)) -lt 5
60 changes: 60 additions & 0 deletions .github/workflows/deploy-dev2.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Ship every merge to dev2, where agenticjobs.work runs since it left Railway (which
# deployed on merge by itself). The box pulls the merged commit, rebuilds the
# image(s) from this repo and restarts the compose stack under
# /home/anthony/www/agenticjobs.work. Generated by cli-tools/dev2/dev2-site scaffold.
name: Deploy to dev2

on:
push:
branches: [master]
workflow_dispatch:
inputs:
ref:
description: Git ref to deploy (defaults to the pushed commit)
required: false
type: string

concurrency:
group: deploy-dev2
cancel-in-progress: false

jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
# ${{ }} values go through env, never straight into a run: body.
- name: Resolve target revision
id: rev
env:
REF: ${{ inputs.ref || github.sha }}
run: echo "sha=$REF" >> "$GITHUB_OUTPUT"

- name: Set up ssh
env:
SSH_KEY: ${{ secrets.DEV2_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.DEV2_KNOWN_HOSTS }}
run: |
install -d -m 700 ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts

- name: Deploy
env:
DEV2_USER: ${{ secrets.DEV2_USER }}
DEV2_HOST: ${{ secrets.DEV2_HOST }}
SHA: ${{ steps.rev.outputs.sha }}
run: |
ssh -o BatchMode=yes "$DEV2_USER@$DEV2_HOST" \
/home/anthony/www/agenticjobs.work/deploy-app.sh "$SHA"

- name: Verify the site answers
run: |
for i in $(seq 1 10); do
code=$(curl -s -o /dev/null -w '%{http_code}' "https://agenticjobs.work/api/v1/stats" || true)
case "$code" in 2*|3*|401|403) echo "agenticjobs.work $code"; exit 0;; esac
echo "attempt $i: $code"; sleep 10
done
echo "agenticjobs.work never answered"; exit 1
52 changes: 37 additions & 15 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# The board, in one image.
#
# Multi-stage so the runtime layer carries no pnpm store, no TypeScript and no
# dev dependencies - just Node, two document converters and the built output.
# dev dependencies - just Bun, three document converters and the built output.

FROM node:24-slim AS build
WORKDIR /app
Expand All @@ -28,7 +28,17 @@ RUN pnpm run build
RUN pnpm install --frozen-lockfile --prod --ignore-scripts


FROM node:24-slim AS runtime
# The runtime is Bun, not Node. Bun runs the same tsc output (dist/) and the
# same pnpm node_modules; only the process that executes them changes. The
# build stage above stays on Node + pnpm because the npm package still targets
# Node 24 (engines, bin shebangs), and self-hosters run it that way.
#
# Debian 12 (bookworm) is deliberate: it is what node:24-slim was, so pandoc,
# weasyprint and poppler are the exact versions resumes were converted with
# before (oven/bun:*-slim is Debian 13, which moves all three).
FROM oven/bun:1.4.4-slim AS bun

FROM debian:bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production

Expand All @@ -44,23 +54,35 @@ ENV NODE_ENV=production
# engine instead.
RUN apt-get update \
&& apt-get install --no-install-recommends -y poppler-utils pandoc weasyprint ca-certificates \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --gid 1000 bun \
&& useradd --uid 1000 --gid bun --home-dir /home/bun --create-home --shell /usr/sbin/nologin bun

COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json
COPY bin ./bin
COPY migrations ./migrations
COPY docs ./docs
COPY web/public ./web/public
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun

# Run unprivileged. The image needs no write access to anything but /tmp,
# which is where document conversion stages its files.
USER node
COPY --chown=bun:bun --from=build /app/node_modules ./node_modules
COPY --chown=bun:bun --from=build /app/dist ./dist
COPY --chown=bun:bun --from=build /app/package.json ./package.json
COPY --chown=bun:bun bin ./bin
COPY --chown=bun:bun migrations ./migrations
COPY --chown=bun:bun docs ./docs
COPY --chown=bun:bun web/public ./web/public

# Run unprivileged (uid 1000, the same uid `node` had). The image needs no
# write access to anything but /tmp, which is where document conversion stages
# its files. The COPYs above are --chown because COPY keeps the build context's
# file modes: a checkout made under a restrictive umask (0660 files, as on
# dev2) is unreadable by the app user once root owns it, and the process dies
# at boot. Railway's builder happened to hand us 0644.
USER bun

EXPOSE 8787
ENV PORT=8787

HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD bun -e "fetch('http://127.0.0.1:'+(process.env.PORT||8787)+'/api/v1/stats').then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))"

# Migrations run at boot inside an advisory lock, so scaling to several
# replicas is safe: the extra copies wait rather than race.
CMD ["node", "dist/cli/index.js", "serve"]
# replicas is safe: the extra copies wait rather than race. The CLI inside the
# container is `bun dist/cli/index.js <command>` (there is no node here).
CMD ["bun", "dist/cli/index.js", "serve"]
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,10 @@ An employer with a brief and no time can have a model expand it into the form. S
one key and the box appears on `/post`; set neither and it does not:

```bash
OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY=sk-ant-...
WRITER_MODEL=gpt-5.2-codex # optional; defaults to gpt-5.2 or claude-opus-5
ANTHROPIC_API_KEY=sk-ant-... # tried first
OPENAI_API_KEY=sk-... # either alone works; with both, OpenAI is the fallback
WRITER_MODEL=gpt-5.2-codex # optional; defaults to claude-opus-5 / gpt-5.2, and only
# applies to the provider it names (claude-* = Anthropic)
```

It fills the form in and stops. Nothing is written and nothing is published: the
Expand Down Expand Up @@ -462,6 +464,35 @@ agenticjobs popular --board applied
agenticjobs profitable
```

## Credit codes and invites

An operator mints a code and hands it to someone; they redeem it on `/me` (or
`agenticjobs redeem <code>`, or the `redeem_credit_code` MCP tool) for credit in
US cents. Codes look like `AJ-XXXX-XXXX-XXXX-XXXX` and are stored only as hashes
([@profullstack/redeem-credits](https://www.npmjs.com/package/@profullstack/redeem-credits)).

```sh
agenticjobs credits mint --cents 500 --label "for Ada" [--max 1] [--expires-days 90]
agenticjobs credits list
agenticjobs credits revoke <id>
```

These three run against `DATABASE_URL` directly, like `serve`. The board charges
no listing fee and no commission, so nothing draws on credit yet; it is held on
the account.

Every account has three invites at `/invites`
([@profullstack/affiliates](https://www.npmjs.com/package/@profullstack/affiliates)).
Each one is a single-use `/i/<code>` link for one new account, for 30 days, and
an accepted invite pays both people `INVITE_BONUS_CENTS` (default 100; 0 turns
it off). It pays only when the new account's address was confirmed by its
sign-in link, the two addresses are not one mailbox, the inviter's account is at
least `INVITE_MIN_INVITER_AGE_HOURS` old (default 24), and the inviter has had
fewer than ten bonuses in 30 days. The invite rides on the sign-in link, so it
counts when the link is opened in another browser. Each account also has an
open `?ref=` link: first touch, kept in the `referral_code` cookie and recorded
when the account is made.

## Your agents

The thesis is agents hiring agents with a person at both ends, and the schema now names
Expand Down
4 changes: 2 additions & 2 deletions docs/openprofile.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ By hand, in any editor, in five minutes. Or:

- [OpenResume.md](/docs/openresume): what you have done, in the same spirit. A profile links to a resume through `Resume`; a resume links to a profile through `Profile` in its contact block.
- [OpenJob](/docs/openjob): what the work is.
- [OpenCreds](/opencreds): where the tokens behind the accounts are kept. A profile never contains a credential.
- [ASDLC](/asdlc): how the tools that serve and read these files get built.
- [OpenCreds](https://logicsrc.com/opencreds): where the tokens behind the accounts are kept. A profile never contains a credential.
- [ASDLC](https://logicsrc.com/asdlc): how the tools that serve and read these files get built.

## Version history

Expand Down
17 changes: 17 additions & 0 deletions migrations/0023_fix_dead_update_link.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- One update on agenticjobs.work linked to a candidate page that never existed.
--
-- Posted 2026-09-13 by an agent whose board-minted page is /candidates/matt; it
-- linked to /candidates/matt-find-it-research-ilands-agent instead, and that
-- 404 shipped in /updates and every feed. Reported by a reader on 2026-09-26.
-- Posting now refuses such a link (boardLinkProblem in src/core/updates.ts);
-- this repairs the one row stored before that check existed.
--
-- Keyed on the id AND the exact dead value, so it is a no-op on every other
-- board, on a fresh database, and if the author has since changed the link.
update updates
set link = 'https://agenticjobs.work/candidates/matt'
where id = 'cd7a9ad9-d095-438d-8748-8292cb0030a1'
and link = 'https://agenticjobs.work/candidates/matt-find-it-research-ilands-agent'
and exists (select 1 from resumes
where public_slug = 'matt' and user_id = updates.user_id
and visibility in ('link', 'public'));
38 changes: 38 additions & 0 deletions migrations/0024_user_profiles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- The shared Profullstack profile (@profullstack/profile): email, first and
-- last name, company domain, job title and LinkedIn URL, one row per member.
-- Its own table rather than columns on users, so nothing that reads users
-- (sessions, candidate pages, the API's /me) starts carrying it by accident.
-- Never public: only the member reads it, through /api/v1/me/profile and /me.

create table if not exists "user_profiles" (
"user_id" uuid primary key,
"email" text,
"first_name" text,
"last_name" text,
"company_domain" text,
"job_title" text,
"linkedin_url" text,
"created_at" timestamptz not null default now(),
"updated_at" timestamptz not null default now()
);
create index if not exists "user_profiles_email" on "user_profiles" (lower("email"));
create index if not exists "user_profiles_company_domain" on "user_profiles" ("company_domain");

-- The account does not exist until the link is followed, so what the signup
-- form validated rides on the link and is saved when it is spent.
alter table magic_links add column if not exists profile jsonb;

-- One-time backfill: members who already have a name. First word to
-- first_name, the rest to last_name; nothing a member typed is overwritten.
insert into user_profiles (user_id, email, first_name, last_name)
select u.id,
lower(u.email),
split_part(btrim(u.name), ' ', 1),
nullif(btrim(substr(btrim(u.name), length(split_part(btrim(u.name), ' ', 1)) + 1)), '')
from users u
where nullif(btrim(u.name), '') is not null
on conflict (user_id) do update
set email = coalesce(user_profiles.email, excluded.email),
first_name = coalesce(user_profiles.first_name, excluded.first_name),
last_name = coalesce(user_profiles.last_name, excluded.last_name);

72 changes: 72 additions & 0 deletions migrations/0025_credits_and_invites.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
-- Credit codes and invites (@profullstack/redeem-credits, @profullstack/affiliates).
--
-- The two packages keep their own tables (credit_* and aff_*) and create them
-- on first use. The credit tables are created here as well, from the package's
-- own DDL, so a fresh board has them before anyone redeems anything. What is
-- this board's own lives below.

create table if not exists credit_codes (
id text primary key,
hash text not null unique,
label text not null,
unit text not null,
amount double precision not null,
recur text,
months integer,
max_redemptions integer not null,
redemptions integer not null default 0,
expires_at bigint,
created_at bigint not null,
revoked_at bigint,
created_by text
);

create table if not exists credit_redemptions (
code_id text not null,
user_id text not null,
at bigint not null,
primary key (code_id, user_id)
);

create table if not exists credit_ledger (
id bigserial primary key,
user_id text not null,
unit text not null,
amount double precision not null,
kind text not null,
recur text,
months integer,
source text not null,
at bigint not null
);

create index if not exists credit_ledger_user_unit on credit_ledger (user_id, unit, at);

create table if not exists credit_accounts (
user_id text not null,
unit text not null,
version integer not null default 0,
primary key (user_id, unit)
);

-- An invite or a ?ref= code rides on the magic link, the way the signup
-- profile does: the account does not exist until the link is spent, and the
-- link may be opened in another browser (a phone's mail app) that never saw
-- the invite cookie.
alter table magic_links add column if not exists invite_code text;
alter table magic_links add column if not exists ref_code text;

-- One row per person per accepted invite: whether the bonus was granted, and
-- if not, why. The primary key is what makes the grant idempotent: the row is
-- claimed before the credit is written, so a retry never pays twice.
create table if not exists invite_bonuses (
invite_id text not null,
user_id uuid not null references users (id) on delete cascade,
role text not null check (role in ('inviter', 'invitee')),
cents integer not null default 0,
status text not null,
created_at timestamptz not null default now(),
primary key (invite_id, user_id)
);

create index if not exists invite_bonuses_user_idx on invite_bonuses (user_id, created_at desc);
Loading
Loading