diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0be79c..d1962c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.github/workflows/deploy-dev2.yml b/.github/workflows/deploy-dev2.yml new file mode 100644 index 0000000..21bbe00 --- /dev/null +++ b/.github/workflows/deploy-dev2.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index b2b7482..dd198e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -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 @@ -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 ` (there is no node here). +CMD ["bun", "dist/cli/index.js", "serve"] diff --git a/README.md b/README.md index 83c7926..daa6cd0 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 `, 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 +``` + +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/` 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 diff --git a/docs/openprofile.md b/docs/openprofile.md index 0cffbc9..d9590f1 100644 --- a/docs/openprofile.md +++ b/docs/openprofile.md @@ -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 diff --git a/migrations/0023_fix_dead_update_link.sql b/migrations/0023_fix_dead_update_link.sql new file mode 100644 index 0000000..67d7e42 --- /dev/null +++ b/migrations/0023_fix_dead_update_link.sql @@ -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')); diff --git a/migrations/0024_user_profiles.sql b/migrations/0024_user_profiles.sql new file mode 100644 index 0000000..e53c1c1 --- /dev/null +++ b/migrations/0024_user_profiles.sql @@ -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); + diff --git a/migrations/0025_credits_and_invites.sql b/migrations/0025_credits_and_invites.sql new file mode 100644 index 0000000..d195f3b --- /dev/null +++ b/migrations/0025_credits_and_invites.sql @@ -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); diff --git a/migrations/0026_coinpay_drop_tokens.sql b/migrations/0026_coinpay_drop_tokens.sql new file mode 100644 index 0000000..48d0c55 --- /dev/null +++ b/migrations/0026_coinpay_drop_tokens.sql @@ -0,0 +1,25 @@ +-- CoinPay tokens are no longer kept. +-- +-- coinpay_accounts held each person's CoinPay access and refresh tokens in +-- plaintext. The board only ever used them to re-read the person's wallets, +-- and the wallets were already stored, so the board now keeps the wallets and +-- nothing that can act as the person on CoinPay. New connections write no +-- token; existing rows are converted by the boot-time sweep (one refresh, one +-- userinfo read, tokens nulled in the same update), and with +-- LEGACY_KEY_MIGRATION=off the sweep just deletes what is left. +-- +-- Additive only: the columns become nullable and stay until every row is +-- clear. `agenticjobs coinpay-sweep` reports how many still hold a token. + +alter table coinpay_accounts alter column access_token drop not null; +alter table coinpay_accounts alter column expires_at drop not null; +alter table coinpay_accounts add column if not exists wallets_synced_at timestamptz; + +-- Every existing row read its wallets at connect time or on a later refresh, +-- both of which bumped updated_at. +update coinpay_accounts set wallets_synced_at = updated_at where wallets_synced_at is null; + +comment on column coinpay_accounts.access_token is + 'Legacy (pre-0026) plaintext token. Never written now; nulled by the coinpay sweep.'; +comment on column coinpay_accounts.refresh_token is + 'Legacy (pre-0026) plaintext token. Never written now; nulled by the coinpay sweep.'; diff --git a/package.json b/package.json index 4440844..2c2f299 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/agenticjobs", - "version": "0.21.0", + "version": "0.23.1", "description": "An agent-friendly job board you self-host. It posts its own jobs, never scrapes anyone else's, and answers on every surface: web, API, MCP, CLI, TUI, desktop and PWA. Instances find each other through an open directory.", "license": "MIT", "type": "module", @@ -58,20 +58,25 @@ "test:only": "node --test \"test/*.test.ts\"" }, "dependencies": { - "@hono/node-server": "^1.14.0", + "@hono/node-server": "^2.1.5", + "@profullstack/affiliates": "^0.2.0", "@profullstack/emailer": "^1.0.3", - "@profullstack/hqtui": "^0.5.0", + "@profullstack/footer": "latest", + "@profullstack/hqtui": "^0.11.1", "@profullstack/leaderboard": "^0.3.1", - "@profullstack/synconfig": "^0.1.2", - "@simplewebauthn/server": "^14.0.1", - "hono": "^4.6.14", - "pg": "^8.13.1" + "@profullstack/profile": "0.1.6", + "@profullstack/redeem-credits": "^0.2.0", + "@profullstack/synconfig": "^0.1.3", + "@profullstack/x402-gateway": "^0.16.0", + "@simplewebauthn/server": "^14.0.3", + "hono": "^4.13.14", + "pg": "^8.23.1" }, "devDependencies": { - "@types/node": "^24.3.0", - "@types/pg": "^8.11.10", - "prettier": "^3.3.3", - "typescript": "^5.9.2" + "@types/node": "^24.19.2", + "@types/pg": "^8.23.1", + "prettier": "^3.9.10", + "typescript": "^7.0.2" }, "exports": { ".": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac63ec0..2bfe6f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,42 +9,57 @@ importers: .: dependencies: '@hono/node-server': - specifier: ^1.14.0 - version: 1.19.17(hono@4.13.7) + specifier: ^2.1.5 + version: 2.1.5(hono@4.13.14) + '@profullstack/affiliates': + specifier: ^0.2.0 + version: 0.2.0 '@profullstack/emailer': specifier: ^1.0.3 version: 1.0.3 - '@profullstack/hqtui': - specifier: ^0.5.0 + '@profullstack/footer': + specifier: latest version: 0.5.0 + '@profullstack/hqtui': + specifier: ^0.11.1 + version: 0.11.1 '@profullstack/leaderboard': specifier: ^0.3.1 version: 0.3.1 + '@profullstack/profile': + specifier: 0.1.6 + version: 0.1.6 + '@profullstack/redeem-credits': + specifier: ^0.2.0 + version: 0.2.0 '@profullstack/synconfig': - specifier: ^0.1.2 - version: 0.1.2 + specifier: ^0.1.3 + version: 0.1.3 + '@profullstack/x402-gateway': + specifier: ^0.16.0 + version: 0.16.0 '@simplewebauthn/server': - specifier: ^14.0.1 - version: 14.0.1 + specifier: ^14.0.3 + version: 14.0.3 hono: - specifier: ^4.6.14 - version: 4.13.7 + specifier: ^4.13.14 + version: 4.13.14 pg: - specifier: ^8.13.1 - version: 8.23.0 + specifier: ^8.23.1 + version: 8.23.1 devDependencies: '@types/node': - specifier: ^24.3.0 - version: 24.13.3 + specifier: ^24.19.2 + version: 24.19.2 '@types/pg': - specifier: ^8.11.10 + specifier: ^8.23.1 version: 8.23.1 prettier: - specifier: ^3.3.3 - version: 3.9.6 + specifier: ^3.9.10 + version: 3.9.10 typescript: - specifier: ^5.9.2 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 myna-plugin: dependencies: @@ -57,113 +72,289 @@ packages: '@hexagon/base64@1.1.28': resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} - '@hono/node-server@1.19.17': - resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.1.5': + resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==} + engines: {node: '>=20'} peerDependencies: - hono: ^4 + hono: ^4 || ^5.0.0-0 '@levischuck/tiny-cbor@0.2.11': resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} - '@peculiar/asn1-android@2.9.4': - resolution: {integrity: sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==} + '@peculiar/asn1-android@2.11.1': + resolution: {integrity: sha512-AuUBRDNdWrgtnuJGXARa0Xdtq6OAD8GqezDQz6Qka7kyynMo9fzkcrtpBEHuX984uEB570BrhMZfJfkUDfpQfw==} engines: {node: '>=14'} - '@peculiar/asn1-asym-key@2.9.4': - resolution: {integrity: sha512-s7SJfjcXlR3MYMngDTeMLCy3VjGaIxeEy905CQ0j09pDbeYhNBvBGA7r7cAvCZYkVFo9kVg9RAki0K2iUz7SGQ==} + '@peculiar/asn1-asym-key@2.11.1': + resolution: {integrity: sha512-YtXR0FbDtjHuNGvWXD4HFE7th5xYwG67O+ApDH/FZsjj5MOrWHzDvqwZ1vm6uFUJtMAkUDcF6VMPGOJ5gnLyUA==} engines: {node: '>=14'} - '@peculiar/asn1-cms@2.9.4': - resolution: {integrity: sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==} + '@peculiar/asn1-cms@2.11.1': + resolution: {integrity: sha512-Bk+oatE62dW0Tzzpfk6vdQxfGGEfvuGdWgmijnKjcv7HqZCJISs85z4zw2uDArrk+R1Oel1wts/BBl7HHrtAcA==} engines: {node: '>=14'} - '@peculiar/asn1-csr@2.9.4': - resolution: {integrity: sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==} + '@peculiar/asn1-csr@2.11.1': + resolution: {integrity: sha512-KVfbXaKng8886/tOPG55t5K1XkTkGbR4amjIJzytXWobFU5nZgw/sKnSpa1hxP17nUSsrOF/7Mt9WUYeyLsi6Q==} engines: {node: '>=14'} - '@peculiar/asn1-ecc@2.9.4': - resolution: {integrity: sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==} + '@peculiar/asn1-ecc@2.11.1': + resolution: {integrity: sha512-7MSNji77fQa/oN3WAfwagMy8EoJFg50sW4hZ1dtr0zU1BSugit0I2DGpIdPhvkNplopQhz5bogxpn1077eaamA==} engines: {node: '>=14'} - '@peculiar/asn1-pfx@2.9.4': - resolution: {integrity: sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==} + '@peculiar/asn1-pfx@2.11.1': + resolution: {integrity: sha512-2SN2z8qkc2hk/GgTYA8RN54wtsCDZShg9gI2Mq1vLhdj1adcv5fhTbiEmExZUaAMqlizxiEpULQDfHM5iYtn1g==} engines: {node: '>=14'} - '@peculiar/asn1-pkcs8@2.9.4': - resolution: {integrity: sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==} + '@peculiar/asn1-pkcs8@2.11.1': + resolution: {integrity: sha512-HqsSrnpQghRp9SM9QSmR6fa4z/BNktnKq7928+oq4qF0VAnuHZ4cyePKvcxh9fP4taBCP/WHNItlVNsYaQm2qw==} engines: {node: '>=14'} - '@peculiar/asn1-pkcs9@2.9.4': - resolution: {integrity: sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==} + '@peculiar/asn1-pkcs9@2.11.1': + resolution: {integrity: sha512-OHFkTg8D2zYafILqFE4KPbqX8srpvd3diGDc2h2cduumawc765mOHGcuFMGvLWlL2bbCMWMmMSCtlXptsOuiPA==} engines: {node: '>=14'} - '@peculiar/asn1-rsa@2.9.4': - resolution: {integrity: sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==} + '@peculiar/asn1-rsa@2.11.1': + resolution: {integrity: sha512-6KwKGjulF489CpidZqkogNgLj6oGzgC4BL2FClM8PuJQ4t5NqT4q4a6A4vrSxBIBhMnL8hGYkLOpbVw0Vq38Tw==} engines: {node: '>=14'} - '@peculiar/asn1-schema@2.9.4': - resolution: {integrity: sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==} + '@peculiar/asn1-schema@2.11.1': + resolution: {integrity: sha512-jaU9OaCcbZUdyXx9hMETZB9xoNmxTGjOGFqd3cOwKFkpKnyKd9WxF3uqj4PvX4cyOMP9bSdHg5YyML2qtx/d/g==} engines: {node: '>=14'} - '@peculiar/asn1-x509-attr@2.9.4': - resolution: {integrity: sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==} + '@peculiar/asn1-x509-attr@2.11.1': + resolution: {integrity: sha512-n7RKFrbTzt8pGBlZ90kmKEn1qsLTTyw7lrvHMK0xsA42kQugPk/GXYpNbEcl7DkkdVsU/one5UX7KQPfxLj8vQ==} engines: {node: '>=14'} - '@peculiar/asn1-x509-post-quantum@2.9.4': - resolution: {integrity: sha512-GD0k7BY3dsEIeai7C7bVRPddj/PzZu+sTawRPRxdHKbdy1f9b58WQyk0eFJdr28GShyLVTU8poyi4+bTAZfEtQ==} + '@peculiar/asn1-x509-post-quantum@2.11.1': + resolution: {integrity: sha512-yD/pxCQ97nzwF7MGAW4kRd1WVLiMPhD39HxOnAr38DfGFwbpVhPnMbq+r6WX0XUrxn4w8ulsTdUafwVl46igLA==} engines: {node: '>=14'} - '@peculiar/asn1-x509@2.9.4': - resolution: {integrity: sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==} + '@peculiar/asn1-x509@2.11.1': + resolution: {integrity: sha512-SXU4/X6B3FquqQAN6ARI0bFDUV7+L1WlvPEPMKzEMkstd2D7UyM0r93fv241+OjdhqAKYGx5MvV3gzZMAp0APA==} engines: {node: '>=14'} '@peculiar/utils@2.0.3': resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} - '@peculiar/x509@2.1.0': - resolution: {integrity: sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==} + '@peculiar/x509@2.2.0': + resolution: {integrity: sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q==} engines: {node: '>=20.0.0'} + '@profullstack/affiliates@0.2.0': + resolution: {integrity: sha512-fbP5uiS8Pa5g8mk5WFD79U4Cj7BUPH6Qy0tQP7ZXE9gfxHOHirHASmZ/rAk8MqnmzGOZTuwq9WwOHbmoFV2TSA==} + engines: {node: '>=22.13'} + hasBin: true + '@profullstack/emailer@1.0.3': resolution: {integrity: sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw==} - '@profullstack/hqtui@0.5.0': - resolution: {integrity: sha512-jDwILBmdQx8pQA0ao/tzzty8u5nFNS4XhvU9xXhZvdIaMeiF3HBJIVtP6cS6kigS4lXmHR7sztbb9QJpaWwhig==} + '@profullstack/footer@0.5.0': + resolution: {integrity: sha512-TrllS87nK7J0c/UgYckaFIWVhLOtr6m73XXFSHIuSfOg7NfuWUdwjtsfMXGaZiiUnmMYj3zhGcfzW0DicDwl8w==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + react: '>=18' + peerDependenciesMeta: + react: + optional: true + + '@profullstack/hqtui@0.11.1': + resolution: {integrity: sha512-c/UzTmiRDjdLZQEO/gdVinUKtx6Q0WBPdixIm90MBTvC3DqFPqpersRs0BIDPS7XVokCSr1DtELI91kj5J1kCg==} engines: {bun: '>=1.1', node: '>=22.6'} hasBin: true + '@profullstack/keys@0.1.0': + resolution: {integrity: sha512-Vys47LvS5ivhdZu4SYOxh9Le3DEmWhjP0+CtrU45RhXAbY0ZFG/2Dk9V1bIIUGDp8UWMJqctvUD21JrNjfh04w==} + engines: {node: '>=20.11'} + '@profullstack/leaderboard@0.3.1': resolution: {integrity: sha512-LbxLwy+RvT/Qez0mfWCk4o9hvcq0kzwBZ0vF+CYp+oGvyqJWUaSTLhCHWGtKKAI3c0ZUXj6ll0iuRjbuZUE2Sg==} engines: {node: '>=20.11'} - '@profullstack/synconfig@0.1.2': - resolution: {integrity: sha512-t1Pdms+k/+A7tgFCAcNx8Dry0kwboPEue9Pw2pbn/chXSMfp1dxrcFz672zWkDgvcB7YBmYpIjnvW4NkNkE4Rg==} + '@profullstack/profile@0.1.6': + resolution: {integrity: sha512-xkn0P3ANz151QJdlBwcBXkHk813qW9qAmuTZKcuvpEZYkyHDC9J9KfvpVSRepfjECzz4ONGHWO5Zwc4BHpfK8g==} + engines: {node: '>=20'} + hasBin: true + peerDependencies: + '@libsql/client': '>=0.6.0' + postgres: ^3.4.0 + react: '>=18' + peerDependenciesMeta: + '@libsql/client': + optional: true + postgres: + optional: true + react: + optional: true + + '@profullstack/redeem-credits@0.2.0': + resolution: {integrity: sha512-JLowb7mWYfouIK7O522tuPPhvLjQjhdEfm6g3xEYrlNLGzLhrIADDhrYldBlGHfiVBWUFX7wefLuCSLF9fnyog==} + engines: {node: '>=20'} + hasBin: true + + '@profullstack/referrals@0.1.0': + resolution: {integrity: sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg==} + engines: {node: '>=18'} + peerDependencies: + react: '>=18' + peerDependenciesMeta: + react: + optional: true + + '@profullstack/synconfig@0.1.3': + resolution: {integrity: sha512-Ty/90ibgu2DVlablDeoFdItcobK+c8cMQFvuTfJOUlCfyvQZWoPjgt0JHPwTvq+ynOQ4i1ivBQ3K3PY/S4rObA==} engines: {node: '>=22'} - '@simplewebauthn/server@14.0.1': - resolution: {integrity: sha512-kjWgcm9NdSv+Tobo4Swxd1WnEawsBogpYFMjg9pKsVLPN3FdCmR6BipkL+DcPDgmQ7UKbbBZqRqn3E3YaA7NBw==} + '@profullstack/x402-gateway@0.16.0': + resolution: {integrity: sha512-3xN7vlGXmoqdexR7NNAIExZLtYvE5M8nR9fqr7kwLK/xks06lUiymEsjZOc2wCbowfnCVNiZBOe98rvsCE9Mgw==} + engines: {node: '>=20.11'} + + '@simplewebauthn/server@14.0.3': + resolution: {integrity: sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q==} engines: {node: '>=20.0.0'} - '@types/node@24.13.3': - resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/node@24.19.2': + resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} + + '@types/node@26.6.5': + resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} '@types/pg@8.23.1': resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} - asn1js@3.0.10: - resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + asn1js@3.1.0: + resolution: {integrity: sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==} engines: {node: '>=12.0.0'} - hono@4.13.7: - resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + hono@4.13.14: + resolution: {integrity: sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ==} engines: {node: '>=16.9.0'} - pg-cloudflare@1.4.0: - resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + pg-cloudflare@1.4.1: + resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} - pg-connection-string@2.14.0: - resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + pg-connection-string@2.14.1: + resolution: {integrity: sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} @@ -174,15 +365,15 @@ packages: peerDependencies: pg: '>=8.0' - pg-protocol@1.16.0: - resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} + pg-protocol@1.16.1: + resolution: {integrity: sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.23.0: - resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + pg@8.23.1: + resolution: {integrity: sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -209,8 +400,8 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} - prettier@3.9.6: - resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + prettier@3.9.10: + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} engines: {node: '>=14'} hasBin: true @@ -238,13 +429,16 @@ packages: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} hasBin: true - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} @@ -254,179 +448,263 @@ snapshots: '@hexagon/base64@1.1.28': {} - '@hono/node-server@1.19.17(hono@4.13.7)': + '@hono/node-server@2.1.5(hono@4.13.14)': dependencies: - hono: 4.13.7 + hono: 4.13.14 '@levischuck/tiny-cbor@0.2.11': {} - '@peculiar/asn1-android@2.9.4': + '@peculiar/asn1-android@2.11.1': dependencies: - '@peculiar/asn1-schema': 2.9.4 - asn1js: 3.0.10 + '@peculiar/asn1-schema': 2.11.1 + asn1js: 3.1.0 tslib: 2.8.1 - '@peculiar/asn1-asym-key@2.9.4': + '@peculiar/asn1-asym-key@2.11.1': dependencies: - '@peculiar/asn1-pkcs8': 2.9.4 - '@peculiar/asn1-schema': 2.9.4 - '@peculiar/asn1-x509': 2.9.4 - asn1js: 3.0.10 + '@peculiar/asn1-pkcs8': 2.11.1 + '@peculiar/asn1-schema': 2.11.1 + '@peculiar/asn1-x509': 2.11.1 + asn1js: 3.1.0 tslib: 2.8.1 - '@peculiar/asn1-cms@2.9.4': + '@peculiar/asn1-cms@2.11.1': dependencies: - '@peculiar/asn1-schema': 2.9.4 - '@peculiar/asn1-x509': 2.9.4 - '@peculiar/asn1-x509-attr': 2.9.4 - asn1js: 3.0.10 + '@peculiar/asn1-schema': 2.11.1 + '@peculiar/asn1-x509': 2.11.1 + '@peculiar/asn1-x509-attr': 2.11.1 + asn1js: 3.1.0 tslib: 2.8.1 - '@peculiar/asn1-csr@2.9.4': + '@peculiar/asn1-csr@2.11.1': dependencies: - '@peculiar/asn1-schema': 2.9.4 - '@peculiar/asn1-x509': 2.9.4 - asn1js: 3.0.10 + '@peculiar/asn1-schema': 2.11.1 + '@peculiar/asn1-x509': 2.11.1 + asn1js: 3.1.0 tslib: 2.8.1 - '@peculiar/asn1-ecc@2.9.4': + '@peculiar/asn1-ecc@2.11.1': dependencies: - '@peculiar/asn1-schema': 2.9.4 - '@peculiar/asn1-x509': 2.9.4 - asn1js: 3.0.10 + '@peculiar/asn1-schema': 2.11.1 + '@peculiar/asn1-x509': 2.11.1 + asn1js: 3.1.0 tslib: 2.8.1 - '@peculiar/asn1-pfx@2.9.4': + '@peculiar/asn1-pfx@2.11.1': dependencies: - '@peculiar/asn1-cms': 2.9.4 - '@peculiar/asn1-pkcs8': 2.9.4 - '@peculiar/asn1-rsa': 2.9.4 - '@peculiar/asn1-schema': 2.9.4 - asn1js: 3.0.10 + '@peculiar/asn1-cms': 2.11.1 + '@peculiar/asn1-pkcs8': 2.11.1 + '@peculiar/asn1-rsa': 2.11.1 + '@peculiar/asn1-schema': 2.11.1 + asn1js: 3.1.0 tslib: 2.8.1 - '@peculiar/asn1-pkcs8@2.9.4': + '@peculiar/asn1-pkcs8@2.11.1': dependencies: - '@peculiar/asn1-schema': 2.9.4 - '@peculiar/asn1-x509': 2.9.4 - asn1js: 3.0.10 + '@peculiar/asn1-schema': 2.11.1 + '@peculiar/asn1-x509': 2.11.1 + asn1js: 3.1.0 tslib: 2.8.1 - '@peculiar/asn1-pkcs9@2.9.4': + '@peculiar/asn1-pkcs9@2.11.1': dependencies: - '@peculiar/asn1-cms': 2.9.4 - '@peculiar/asn1-pfx': 2.9.4 - '@peculiar/asn1-pkcs8': 2.9.4 - '@peculiar/asn1-schema': 2.9.4 - '@peculiar/asn1-x509': 2.9.4 - '@peculiar/asn1-x509-attr': 2.9.4 - asn1js: 3.0.10 + '@peculiar/asn1-cms': 2.11.1 + '@peculiar/asn1-pfx': 2.11.1 + '@peculiar/asn1-pkcs8': 2.11.1 + '@peculiar/asn1-schema': 2.11.1 + '@peculiar/asn1-x509': 2.11.1 + '@peculiar/asn1-x509-attr': 2.11.1 + asn1js: 3.1.0 tslib: 2.8.1 - '@peculiar/asn1-rsa@2.9.4': + '@peculiar/asn1-rsa@2.11.1': dependencies: - '@peculiar/asn1-schema': 2.9.4 - '@peculiar/asn1-x509': 2.9.4 - asn1js: 3.0.10 + '@peculiar/asn1-schema': 2.11.1 + '@peculiar/asn1-x509': 2.11.1 + asn1js: 3.1.0 tslib: 2.8.1 - '@peculiar/asn1-schema@2.9.4': + '@peculiar/asn1-schema@2.11.1': dependencies: '@peculiar/utils': 2.0.3 - asn1js: 3.0.10 + asn1js: 3.1.0 tslib: 2.8.1 - '@peculiar/asn1-x509-attr@2.9.4': + '@peculiar/asn1-x509-attr@2.11.1': dependencies: - '@peculiar/asn1-schema': 2.9.4 - '@peculiar/asn1-x509': 2.9.4 - asn1js: 3.0.10 + '@peculiar/asn1-schema': 2.11.1 + '@peculiar/asn1-x509': 2.11.1 + asn1js: 3.1.0 tslib: 2.8.1 - '@peculiar/asn1-x509-post-quantum@2.9.4': + '@peculiar/asn1-x509-post-quantum@2.11.1': dependencies: - '@peculiar/asn1-asym-key': 2.9.4 - '@peculiar/asn1-schema': 2.9.4 - '@peculiar/asn1-x509': 2.9.4 - asn1js: 3.0.10 + '@peculiar/asn1-asym-key': 2.11.1 + '@peculiar/asn1-schema': 2.11.1 + '@peculiar/asn1-x509': 2.11.1 + asn1js: 3.1.0 tslib: 2.8.1 - '@peculiar/asn1-x509@2.9.4': + '@peculiar/asn1-x509@2.11.1': dependencies: - '@peculiar/asn1-schema': 2.9.4 + '@peculiar/asn1-schema': 2.11.1 '@peculiar/utils': 2.0.3 - asn1js: 3.0.10 + asn1js: 3.1.0 tslib: 2.8.1 '@peculiar/utils@2.0.3': dependencies: tslib: 2.8.1 - '@peculiar/x509@2.1.0': + '@peculiar/x509@2.2.0': dependencies: - '@peculiar/asn1-cms': 2.9.4 - '@peculiar/asn1-csr': 2.9.4 - '@peculiar/asn1-ecc': 2.9.4 - '@peculiar/asn1-pkcs9': 2.9.4 - '@peculiar/asn1-rsa': 2.9.4 - '@peculiar/asn1-schema': 2.9.4 - '@peculiar/asn1-x509': 2.9.4 - '@peculiar/asn1-x509-post-quantum': 2.9.4 + '@peculiar/asn1-cms': 2.11.1 + '@peculiar/asn1-csr': 2.11.1 + '@peculiar/asn1-ecc': 2.11.1 + '@peculiar/asn1-pkcs9': 2.11.1 + '@peculiar/asn1-rsa': 2.11.1 + '@peculiar/asn1-schema': 2.11.1 + '@peculiar/asn1-x509': 2.11.1 + '@peculiar/asn1-x509-post-quantum': 2.11.1 pvtsutils: 1.3.6 tslib: 2.8.1 tsyringe: 4.10.0 + '@profullstack/affiliates@0.2.0': + dependencies: + '@profullstack/referrals': 0.1.0 + transitivePeerDependencies: + - react + '@profullstack/emailer@1.0.3': {} - '@profullstack/hqtui@0.5.0': {} + '@profullstack/footer@0.5.0': {} + + '@profullstack/hqtui@0.11.1': {} + + '@profullstack/keys@0.1.0': {} '@profullstack/leaderboard@0.3.1': {} - '@profullstack/synconfig@0.1.2': {} + '@profullstack/profile@0.1.6': {} + + '@profullstack/redeem-credits@0.2.0': {} + + '@profullstack/referrals@0.1.0': {} + + '@profullstack/synconfig@0.1.3': {} - '@simplewebauthn/server@14.0.1': + '@profullstack/x402-gateway@0.16.0': + dependencies: + '@profullstack/keys': 0.1.0 + + '@simplewebauthn/server@14.0.3': dependencies: '@hexagon/base64': 1.1.28 '@levischuck/tiny-cbor': 0.2.11 - '@peculiar/asn1-android': 2.9.4 - '@peculiar/asn1-ecc': 2.9.4 - '@peculiar/asn1-rsa': 2.9.4 - '@peculiar/asn1-schema': 2.9.4 - '@peculiar/asn1-x509': 2.9.4 - '@peculiar/asn1-x509-post-quantum': 2.9.4 - '@peculiar/x509': 2.1.0 + '@peculiar/asn1-android': 2.11.1 + '@peculiar/asn1-ecc': 2.11.1 + '@peculiar/asn1-rsa': 2.11.1 + '@peculiar/asn1-schema': 2.11.1 + '@peculiar/asn1-x509': 2.11.1 + '@peculiar/asn1-x509-post-quantum': 2.11.1 + '@peculiar/x509': 2.2.0 reflect-metadata: 0.2.2 - '@types/node@24.13.3': + '@types/node@24.19.2': dependencies: - undici-types: 7.18.2 + undici-types: 7.24.6 + + '@types/node@26.6.5': + dependencies: + undici-types: 8.9.0 '@types/pg@8.23.1': dependencies: - '@types/node': 24.13.3 - pg-protocol: 1.16.0 + '@types/node': 26.6.5 + pg-protocol: 1.16.1 pg-types: 2.2.0 - asn1js@3.0.10: + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + asn1js@3.1.0: dependencies: pvtsutils: 1.3.6 pvutils: 1.2.0 tslib: 2.8.1 - hono@4.13.7: {} + hono@4.13.14: {} - pg-cloudflare@1.4.0: + pg-cloudflare@1.4.1: optional: true - pg-connection-string@2.14.0: {} + pg-connection-string@2.14.1: {} pg-int8@1.0.1: {} - pg-pool@3.14.0(pg@8.23.0): + pg-pool@3.14.0(pg@8.23.1): dependencies: - pg: 8.23.0 + pg: 8.23.1 - pg-protocol@1.16.0: {} + pg-protocol@1.16.1: {} pg-types@2.2.0: dependencies: @@ -436,15 +714,15 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.23.0: + pg@8.23.1: dependencies: - pg-connection-string: 2.14.0 - pg-pool: 3.14.0(pg@8.23.0) - pg-protocol: 1.16.0 + pg-connection-string: 2.14.1 + pg-pool: 3.14.0(pg@8.23.1) + pg-protocol: 1.16.1 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: - pg-cloudflare: 1.4.0 + pg-cloudflare: 1.4.1 pgpass@1.0.5: dependencies: @@ -460,7 +738,7 @@ snapshots: dependencies: xtend: 4.0.2 - prettier@3.9.6: {} + prettier@3.9.10: {} pvtsutils@1.3.6: dependencies: @@ -480,8 +758,31 @@ snapshots: dependencies: tslib: 1.14.1 - typescript@5.9.3: {} - - undici-types@7.18.2: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@7.24.6: {} + + undici-types@8.9.0: {} xtend@4.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e1af7c9..d27501c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,5 +11,4 @@ packages: # hqtui is published by us, often on the same day we depend on the new version. # pnpm 11 refuses packages younger than a day unless they are listed here. minimumReleaseAgeExclude: - - '@profullstack/hqtui' - - '@profullstack/synconfig@0.1.2' + - '@profullstack/*' diff --git a/railway.json b/railway.json index 5993050..c34770c 100644 --- a/railway.json +++ b/railway.json @@ -5,7 +5,7 @@ "dockerfilePath": "Dockerfile" }, "deploy": { - "startCommand": "node dist/cli/index.js serve", + "startCommand": "bun dist/cli/index.js serve", "healthcheckPath": "/api/v1/stats", "healthcheckTimeout": 60, "restartPolicyType": "ON_FAILURE", diff --git a/src/cli/args.ts b/src/cli/args.ts index 9daad8d..61b0d9d 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -41,12 +41,19 @@ const BOOLEAN_FLAGS = new Set([ 'read', ]); -function takesValue(name: string, next: string | undefined): next is string { +function takesValue( + name: string, + next: string | undefined, + command: string | undefined, +): next is string { const negativeNumber = next !== undefined && /^-\d+(?:\.\d+)?$/.test(next); + // `news --candidate ` filters the feed; other commands use the same + // option as a boolean switch between employer and candidate subjects. + const boolean = BOOLEAN_FLAGS.has(name) && !(command === 'news' && name === 'candidate'); return ( next !== undefined && - (!next.startsWith('-') || negativeNumber) && - (!BOOLEAN_FLAGS.has(name) || /^(true|false|1|0|yes|no)$/i.test(next)) + (next === '-' || !next.startsWith('-') || negativeNumber) && + (!boolean || /^(true|false|1|0|yes|no)$/i.test(next)) ); } @@ -75,7 +82,7 @@ export function parseArgs(argv: string[]): Args { continue; } const next = argv[index + 1]; - if (takesValue(body, next)) { + if (takesValue(body, next, positional[0])) { set(flags, body, next); index += 1; continue; @@ -87,7 +94,7 @@ export function parseArgs(argv: string[]): Args { if (token.startsWith('-') && token.length > 1) { const body = token.slice(1); const next = argv[index + 1]; - if (takesValue(body, next)) { + if (takesValue(body, next, positional[0])) { set(flags, body, next); index += 1; continue; diff --git a/src/cli/credits.ts b/src/cli/credits.ts new file mode 100644 index 0000000..a709543 --- /dev/null +++ b/src/cli/credits.ts @@ -0,0 +1,126 @@ +/** + * Credit and invites from a terminal. + * + * credits balance and history (the board, over the API) + * redeem redeem a credit code + * invites [create --note t] invites and the ?ref= link + * credits mint|list|revoke operator: straight against DATABASE_URL + */ + +import type { BoardClient } from '../client/client.ts'; +import { flagString, type Args } from './args.ts'; +import { bold, dim } from './format.ts'; + +type Out = (human: string, machine: unknown) => number; + +function dollars(cents: number): string { + const abs = Math.abs(Math.round(cents)); + return `${cents < 0 ? '-' : ''}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`; +} + +export async function runCredits(args: Args, client: () => BoardClient, out: Out): Promise { + if (args.command === 'redeem') { + const code = args.positional[0] ?? ''; + if (code === '') { + process.stderr.write('agenticjobs redeem \n'); + return 1; + } + const result = await client().redeemCredit(code); + return out( + `Redeemed: ${bold(dollars(result.addedCents))} added. Balance ${bold(result.balance)}.`, + result, + ); + } + + if (args.command === 'invites') { + if (args.positional[0] === 'create') { + const result = await client().createInvite(flagString(args, 'note') ?? ''); + return out( + `${bold(result.url)}\n${dim(`Shown once. ${result.quota.left} of ${result.quota.total} invites left.`)}`, + result, + ); + } + const result = await client().invites(); + const lines = result.invites.map( + (invite) => + `${invite.used ? 'accepted' : invite.revoked ? 'withdrawn' : 'open '} ${invite.note || dim('no note')} ${dim(invite.createdAt.slice(0, 10))}`, + ); + return out( + [ + `${bold(`${result.quota.left} of ${result.quota.total}`)} invites left. ${dim('agenticjobs invites create --note "for Ada"')}`, + ...lines, + '', + `Your link: ${result.affiliateUrl}`, + ].join('\n'), + result, + ); + } + + // credits + const sub = args.positional[0]; + if (sub === 'mint' || sub === 'list' || sub === 'revoke') return runOperator(args, sub, out); + const result = await client().credits(); + const lines = result.history.map( + (entry) => + `${entry.kind === 'spend' ? '-' : '+'}${dollars(entry.cents)} ${entry.source} ${dim(entry.at.slice(0, 10))}`, + ); + return out([`Balance ${bold(result.balance)}`, ...lines].join('\n'), result); +} + +/** Operator commands: the board's own database, no sign-in, like `serve`. */ +async function runOperator(args: Args, sub: 'mint' | 'list' | 'revoke', out: Out): Promise { + const { closePool, getPool } = await import('../db/pool.ts'); + const { loadConfig } = await import('../config.ts'); + const { migrate } = await import('../db/migrate.ts'); + const { creditServices, mintCreditCode } = await import('../core/credits.ts'); + const pool = getPool(loadConfig().databaseUrl); + try { + await migrate(pool); + if (sub === 'mint') { + const cents = Number.parseInt(flagString(args, 'cents') ?? '', 10); + const label = flagString(args, 'label') ?? ''; + if (!Number.isInteger(cents) || label === '') { + process.stderr.write( + 'agenticjobs credits mint --cents 500 --label "for Ada" [--max 1] [--expires-days 90]\n', + ); + return 1; + } + const max = flagString(args, 'max'); + const days = flagString(args, 'expires-days'); + const minted = await mintCreditCode(pool, { + cents, + label, + maxRedemptions: max === undefined ? 1 : Number.parseInt(max, 10), + expiresInDays: days === undefined ? null : Number.parseInt(days, 10), + createdBy: 'cli', + }); + return out( + `${bold(minted.code)}\n${dim(`${dollars(cents)}, ${minted.maxRedemptions} use(s), id ${minted.id}. Shown once; only its hash is stored.`)}`, + { ...minted, cents }, + ); + } + const { credits } = creditServices(pool); + if (sub === 'revoke') { + const id = args.positional[1] ?? ''; + if (id === '') { + process.stderr.write('agenticjobs credits revoke (ids are in: credits list)\n'); + return 1; + } + await credits.revoke(id); + return out(`Revoked ${id}.`, { revoked: id }); + } + const codes = await credits.list(); + if (codes.length === 0) return out('No codes minted yet.', { items: [] }); + return out( + codes + .map( + (code) => + `${code.id} ${dollars(code.grant.amount)} ${code.redemptions}/${code.maxRedemptions} ${code.label}${code.revokedAt === null ? '' : dim(' revoked')}`, + ) + .join('\n'), + { items: codes }, + ); + } finally { + await closePool(); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index ae8e95e..71a5464 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -140,6 +140,13 @@ const USAGE = `agenticjobs ${VERSION} - an agent-friendly job board you can self applications what came in decide reviewing, rejected or hired + Credit and invites + credits your credit balance and where it came from + redeem redeem a credit code + invites your invites, how many are left, your ?ref= link + invites create [--note t] make an invite (the link is shown once) + signup --invite create an account with an invite + Watching watch [filters] be told when a matching listing is published --remote --agents --tag --workplace --min @@ -171,9 +178,15 @@ const USAGE = `agenticjobs ${VERSION} - an agent-friendly job board you can self tracker forget --fleet --source --yes tracker leaderboard + Operator (runs against DATABASE_URL, like serve) + credits mint --cents --label [--max ] [--expires-days ] + credits list every code: label, uses, revoked + credits revoke + Running one serve start the board migrate apply migrations and exit + coinpay-sweep convert legacy CoinPay tokens to stored wallets; prints what is left seed add example data (never on a board with jobs) announce tell a directory this board exists instances boards a directory knows about @@ -231,6 +244,14 @@ async function run(args: Args): Promise { case 'serve': { const { startServer } = await import('../server/serve.ts'); await startServer(); + // In a container the board is PID 1, and PID 1 gets no default SIGTERM + // action: without a handler every `docker stop` (so every deploy) waits + // the full ten seconds for SIGKILL. Nothing is lost by exiting at once: + // requests in flight are retried by the proxy and the pool holds no + // uncommitted work. + for (const signal of ['SIGTERM', 'SIGINT'] as const) { + process.once(signal, () => process.exit(0)); + } return new Promise(() => undefined); } case 'migrate': { @@ -246,6 +267,19 @@ async function run(args: Args): Promise { await closePool(); return 0; } + case 'coinpay-sweep': { + const { closePool, getPool } = await import('../db/pool.ts'); + const { loadConfig: serverConfig } = await import('../config.ts'); + const { createCoinPay, sweepLegacyTokens } = await import('../core/coinpay.ts'); + const config = serverConfig(); + const coinpay = config.coinpay === null ? null : createCoinPay(config.coinpay); + const result = await sweepLegacyTokens(getPool(config.databaseUrl), coinpay, { + legacy: config.legacyKeyMigration !== false, + }); + process.stdout.write(`${JSON.stringify(result)}\n`); + await closePool(); + return 0; + } case 'seed': { const { closePool, getPool } = await import('../db/pool.ts'); const { seed } = await import('../db/seed.ts'); @@ -485,6 +519,16 @@ async function run(args: Args): Promise { const { runAgents } = await import('./agents.ts'); return runAgents(args, clientFor(args), (human, machine) => out(args, human, machine)); } + case 'credits': + case 'redeem': + case 'invites': { + const { runCredits } = await import('./credits.ts'); + return runCredits( + args, + () => clientFor(args), + (human, machine) => out(args, human, machine), + ); + } case 'watch': { const { runWatch } = await import('./watches.ts'); return runWatch(args, clientFor(args), queryFrom(args), (human, machine) => @@ -672,6 +716,7 @@ async function commandSignup(args: Args): Promise { const sent = await client.request<{ delivered: boolean }>('POST', '/api/v1/auth/magic-link', { email, redirect: `/device?code=${encodeURIComponent(grant.userCode)}`, + ...(flagString(args, 'invite') === undefined ? {} : { invite: flagString(args, 'invite') }), }); if (sent.delivered) { diff --git a/src/cli/jobfile.ts b/src/cli/jobfile.ts index 1cddcfe..7258069 100644 --- a/src/cli/jobfile.ts +++ b/src/cli/jobfile.ts @@ -27,9 +27,13 @@ function firstHeading(text: string): { title: string; start: number; end: number const run = opening[1] ?? '```'; fence = { marker: run[0] ?? '`', length: run.length }; } else { - const heading = /^#\s+(.+)$/.exec(line); + const heading = /^ {0,3}#\s+(.+)$/.exec(line); if (heading !== null) { - return { title: (heading[1] ?? '').trim(), start: offset, end: offset + line.length }; + // A closing run of hashes after a space, or a heading made only of + // them, is heading syntax, as the Markdown renderer reads it; a hash + // attached to a word (C#) is text. + const title = (heading[1] ?? '').trim().replace(/(?:^|[ \t]+)#+$/, ''); + return { title, start: offset, end: offset + line.length }; } } } @@ -54,7 +58,9 @@ export function parseJobDocument(source: string): JobDocument { } const front = parseFrontMatter(match[1] ?? ''); - const body = text.slice(match[0].length).trim(); + // Remove blank opening lines without turning indented code into a heading + // or a code fence. Indentation on the first content line is Markdown syntax. + const body = text.slice(match[0].length).replace(/^(?:[ \t]*\n)+/, '').trimEnd(); const heading = firstHeading(body); const title = front['title'] ?? heading?.title; let description = body; diff --git a/src/cli/watches.ts b/src/cli/watches.ts index db6915a..7b2beef 100644 --- a/src/cli/watches.ts +++ b/src/cli/watches.ts @@ -82,14 +82,25 @@ export async function runNotifications( out: (human: string, machine: unknown) => number, ): Promise { const unreadOnly = flagBool(args, 'unread'); - const result = await client.notifications({ unreadOnly }); - if (flagBool(args, 'read')) await client.markNotificationsRead(); + let result = await client.notifications({ unreadOnly }); + let marked = 0; + if (flagBool(args, 'read')) { + ({ read: marked } = await client.markNotificationsRead()); + const updated = await client.notifications(); + const displayed = new Set(result.items.map((item) => item.id)); + result = { + ...updated, + items: unreadOnly ? updated.items.filter((item) => displayed.has(item.id)) : updated.items, + }; + } if (result.items.length === 0) return out(unreadOnly ? 'Nothing unread.' : 'No notifications yet.', result); const head = - result.unread === 0 - ? '' - : `${bold(`${result.unread} unread`)}${flagBool(args, 'read') ? dim(', now marked read') : dim(' (--read marks them read)')}\n\n`; + marked > 0 + ? `${bold(`${marked} unread`)}${dim(', now marked read')}\n\n` + : result.unread === 0 + ? '' + : `${bold(`${result.unread} unread`)}${dim(flagBool(args, 'read') ? ' (new since marking read)' : ' (--read marks them read)')}\n\n`; return out(head + result.items.map(notificationLine).join('\n\n'), result); } diff --git a/src/client/client.ts b/src/client/client.ts index faf9be4..5a2491c 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -38,6 +38,30 @@ export interface AgentInputRecord { public?: boolean; } +export interface CreditsRecord { + unit: 'usd_cents'; + balanceCents: number; + balance: string; + history: { cents: number; kind: 'grant' | 'spend'; source: string; at: string }[]; +} + +export interface InvitesRecord { + quota: { total: number; used: number; left: number }; + invites: { + id: string; + note: string; + createdAt: string; + expiresAt: string | null; + used: boolean; + usedAt: string | null; + revoked: boolean; + }[]; + affiliateCode: string; + affiliateUrl: string; + invitedCount: number; + bonusCents: number; +} + export interface WatchRecord { id: string; query: JobQuery; @@ -102,6 +126,8 @@ export interface ClientOptions { token?: string | null; fetch?: typeof fetch; timeoutMs?: number; + /** Disable timeout retries when a caller manages a shared overall deadline. */ + retryTimeouts?: boolean; userAgent?: string; } @@ -167,6 +193,7 @@ export class BoardClient { private token: string | null; private readonly doFetch: typeof fetch; private readonly timeoutMs: number; + private readonly retryTimeouts: boolean; private readonly userAgent: string; constructor(server: string, options: ClientOptions = {}) { @@ -174,6 +201,7 @@ export class BoardClient { this.token = options.token ?? null; this.doFetch = options.fetch ?? fetch; this.timeoutMs = options.timeoutMs ?? 20_000; + this.retryTimeouts = options.retryTimeouts ?? true; this.userAgent = options.userAgent ?? 'agenticjobs-client'; } @@ -216,8 +244,15 @@ export class BoardClient { // about a second. GET is safe to repeat. A POST is not, unless it // carries an idempotency key: then the board answers a repeat with // the row the first attempt made, and repeating is the point. - const repeatable = method === 'GET' || options.idempotencyKey !== undefined; - if (repeatable && error instanceof ApiError && error.code === 'timeout') { + // Empty (including HTTP-trimmed whitespace) headers are treated as no + // key by the server, so they cannot make a write safe to repeat. + const repeatable = method === 'GET' || Boolean(options.idempotencyKey?.trim()); + if ( + this.retryTimeouts && + repeatable && + error instanceof ApiError && + error.code === 'timeout' + ) { return this.requestOnce(method, path, body, options); } throw error; @@ -371,6 +406,11 @@ export class BoardClient { return (await this.me()).orgs; } + /** Every listing this account can manage, including drafts and closed jobs. */ + async myJobs(): Promise<{ items: Job[]; total: number }> { + return this.request('GET', '/api/v1/me/jobs'); + } + async createOrg(input: Record): Promise<{ org: Organisation }> { return this.request('POST', '/api/v1/orgs', input); } @@ -499,6 +539,30 @@ export class BoardClient { return this.request('DELETE', `/api/v1/agents/${encodeURIComponent(slug)}`); } + // --- credit and invites ------------------------------------------------ + + async credits(): Promise { + return this.request('GET', '/api/v1/credits'); + } + + async redeemCredit( + code: string, + ): Promise<{ ok: true; addedCents: number; balanceCents: number; balance: string }> { + return this.request('POST', '/api/v1/credits/redeem', { code }); + } + + async invites(): Promise { + return this.request('GET', '/api/v1/invites'); + } + + async createInvite(note = ''): Promise<{ + code: string; + url: string; + quota: { total: number; used: number; left: number }; + }> { + return this.request('POST', '/api/v1/invites', { note }); + } + // --- watches and notifications ------------------------------------------- async watches(): Promise<{ items: WatchRecord[]; total: number }> { @@ -684,18 +748,14 @@ export class BoardClient { async searchNetwork(query: Partial): Promise { const params = queryToParams({ - q: null, - employmentType: null, - workplace: null, - seniority: null, - agentPolicy: null, - tags: [], - salaryMin: null, - org: null, - sort: 'recent', - limit: 25, - offset: 0, + ...EMPTY_QUERY, ...query, + // Match search(): explicitly undefined filters must not erase defaults. + tags: query.tags ?? EMPTY_QUERY.tags, + salaryMin: query.salaryMin ?? EMPTY_QUERY.salaryMin, + sort: query.sort ?? EMPTY_QUERY.sort, + limit: query.limit ?? EMPTY_QUERY.limit, + offset: query.offset ?? EMPTY_QUERY.offset, }); const search = params.toString(); return this.request('GET', `/api/v1/directory/search${search === '' ? '' : `?${search}`}`); diff --git a/src/client/fanout.ts b/src/client/fanout.ts index 6a047aa..93ca719 100644 --- a/src/client/fanout.ts +++ b/src/client/fanout.ts @@ -89,6 +89,9 @@ export async function searchEverywhere( const client = new BoardClient(board.server, { token: board.token, timeoutMs: remainingMs, + // This attempt already has the board's entire remaining budget. + // A cold-start retry would reset its timer and exceed that deadline. + retryTimeouts: false, }); const page = await client.search({ ...query, limit: pageLimit, offset: sourceOffset }); if (Date.now() - started >= budgetMs) { diff --git a/src/client/sync.ts b/src/client/sync.ts index 202ab00..7c492e1 100644 --- a/src/client/sync.ts +++ b/src/client/sync.ts @@ -15,6 +15,7 @@ * API is the cloud. */ import { hostname } from 'node:os'; +import { isDeepStrictEqual } from 'node:util'; import { dirname } from 'node:path'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { @@ -165,7 +166,8 @@ export async function syncLoad( if (synced) { const before = loadConfig(); const applied = applySettings(synced, before); - if (applied.added.length || applied.directoriesAdded.length || applied.current !== before.current) saveConfig(applied.config); + // Existing boards may gain missing metadata even when no board or directory is added. + if (!isDeepStrictEqual(applied.config, before)) saveConfig(applied.config); added = applied.added; directoriesAdded = applied.directoriesAdded; } diff --git a/src/config.ts b/src/config.ts index 97280fa..6420851 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,6 +44,25 @@ export interface Config { * absent from every page rather than present and broken. */ coinpay: CoinPayConfig | null; + /** + * The crawl door (x402-gateway): a scoped CoinPay key and the USDC payee. Both or + * nothing; a board without them never sells crawl passes. Optional so a + * hand-built config in a test can leave it out. + */ + crawl?: { apiKey: string; payTo: string } | null; + /** + * Whether legacy CoinPay tokens (stored in plaintext before 0026) may still + * be used once, to re-read wallets before they are deleted. Off + * (LEGACY_KEY_MIGRATION=off) means they are only ever deleted. Optional so a + * hand-built config in a test can leave it out; absent means on. + */ + legacyKeyMigration?: boolean; + /** + * What an accepted invite is worth, to each side, and how old the inviter's + * account must be before it pays. Optional so a hand-built config in a test + * can leave it out; the defaults are in core/credits.ts. + */ + invites?: { bonusCents: number; inviterMinAgeHours: number }; version: string; } @@ -182,12 +201,27 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { openaiApiKey: env['OPENAI_API_KEY']?.trim() || null, writerModel: env['WRITER_MODEL']?.trim() || null, coinpay: coinpayConfig(env), + legacyKeyMigration: env['LEGACY_KEY_MIGRATION']?.trim().toLowerCase() !== 'off', + crawl: + env['COINPAY_X402_KEY']?.trim() && env['CRAWL_PAY_TO']?.trim() + ? { apiKey: env['COINPAY_X402_KEY'].trim(), payTo: env['CRAWL_PAY_TO'].trim() } + : null, + invites: { + bonusCents: intOr(env['INVITE_BONUS_CENTS'], 100), + inviterMinAgeHours: intOr(env['INVITE_MIN_INVITER_AGE_HOURS'], 24), + }, version: env['npm_package_version']?.trim() || VERSION, }; } +/** A non-negative integer from the environment, or the default. */ +function intOr(value: string | undefined, fallback: number): number { + const parsed = Number.parseInt(value?.trim() ?? '', 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + /** Kept in step with package.json by the release script. */ -export const VERSION = '0.21.0'; +export const VERSION = '0.23.1'; export const SOFTWARE_NAME = 'agenticjobs'; /** diff --git a/src/core/agents.ts b/src/core/agents.ts index c0692e9..495727f 100644 --- a/src/core/agents.ts +++ b/src/core/agents.ts @@ -18,6 +18,8 @@ import type pg from 'pg'; import { clean, parseList, slugify, suffix } from '../schema/text.ts'; +type AgentQueryable = Pick; + export const NAME_MAX = 80; export const DESCRIPTION_MAX = 2000; export const SKILLS_MAX = 20; @@ -117,13 +119,24 @@ export function normaliseSkills(input: unknown): string[] { function normaliseUrl(value: unknown): string | null { const text = clean(value, 500); if (text === '') return null; + let url: URL; try { - const url = new URL(text); - if (url.protocol !== 'https:' && url.protocol !== 'http:') return null; - return url.toString(); + url = new URL(text); } catch { - return null; + throw new AgentProblem( + 'Give the agent a valid HTTP or HTTPS URL, or leave it blank.', + 400, + 'url', + ); + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new AgentProblem( + 'Give the agent a valid HTTP or HTTPS URL, or leave it blank.', + 400, + 'url', + ); } + return url.toString(); } function toAgent(row: AgentRow, operates: AgentRef[]): Agent { @@ -146,7 +159,7 @@ function toAgent(row: AgentRow, operates: AgentRef[]): Agent { }; } -async function operatesOf(pool: pg.Pool, ids: string[]): Promise> { +async function operatesOf(pool: AgentQueryable, ids: string[]): Promise> { const out = new Map(); if (ids.length === 0) return out; const rows = await pool.query<{ operator_id: string; slug: string; name: string }>( @@ -161,7 +174,7 @@ async function operatesOf(pool: pg.Pool, ids: string[]): Promise { +async function hydrate(pool: AgentQueryable, rows: AgentRow[]): Promise { const operates = await operatesOf( pool, rows.map((row) => row.id), @@ -200,7 +213,7 @@ export async function listPublicAgents( /** One agent by slug. Private agents are visible to their owner only. */ export async function getAgent( - pool: pg.Pool, + pool: AgentQueryable, slug: string, viewerId: string | null, ): Promise { @@ -211,7 +224,7 @@ export async function getAgent( return (await hydrate(pool, [row]))[0] ?? null; } -async function ownedRow(pool: pg.Pool, slug: string, ownerId: string): Promise { +async function ownedRow(pool: AgentQueryable, slug: string, ownerId: string): Promise { const rows = await pool.query(`${SELECT} where a.slug = $1`, [slug]); const row = rows.rows[0]; if (row === undefined || row.owner_id !== ownerId) { @@ -225,7 +238,7 @@ async function ownedRow(pool: pg.Pool, slug: string, ownerId: string): Promise( - `select count(*)::int as n from agents where owner_id = $1`, - [ownerId], - ); - if ((count.rows[0]?.n ?? 0) >= AGENTS_PER_ACCOUNT) { - throw new AgentProblem(`An account can register up to ${AGENTS_PER_ACCOUNT} agents.`, 409); - } - const operatorId = (await operatorIdOf(pool, ownerId, input, null)) ?? null; + const description = clean(input.description, DESCRIPTION_MAX, { multiline: true }); + const url = normaliseUrl(input.url); + const isPublic = input.public === undefined ? true : flag(input.public); + const client = await pool.connect(); + try { + await client.query('begin'); + // Serialize registrations for this account before checking its cap. A + // count followed by an insert otherwise lets concurrent requests both + // observe the final available slot. + await client.query('select id from users where id = $1 for update', [ownerId]); + const count = await client.query<{ n: number }>( + `select count(*)::int as n from agents where owner_id = $1`, + [ownerId], + ); + if ((count.rows[0]?.n ?? 0) >= AGENTS_PER_ACCOUNT) { + throw new AgentProblem(`An account can register up to ${AGENTS_PER_ACCOUNT} agents.`, 409); + } + const operatorId = (await operatorIdOf(client, ownerId, input, null)) ?? null; - const base = slugify(name); - let slug = base; - for (let attempt = 0; attempt < 6; attempt += 1) { - const taken = await pool.query(`select 1 from agents where slug = $1`, [slug]); - if (taken.rows.length === 0) break; - slug = `${base}-${suffix(4)}`; - } + const base = slugify(name); + let slug = base; + for (let attempt = 0; attempt < 6; attempt += 1) { + const taken = await client.query(`select 1 from agents where slug = $1`, [slug]); + if (taken.rows.length === 0) break; + slug = `${base}-${suffix(4)}`; + } - const inserted = await pool.query<{ slug: string }>( - `insert into agents (owner_id, slug, name, skills, description, url, operator_id, public) - values ($1, $2, $3, $4, $5, $6, $7, $8) returning slug`, - [ - ownerId, - slug, - name, - skills, - clean(input.description, DESCRIPTION_MAX, { multiline: true }), - normaliseUrl(input.url), - operatorId, - input.public === undefined ? true : flag(input.public), - ], - ); - const created = await getAgent(pool, inserted.rows[0]?.slug ?? slug, ownerId); - if (created === null) throw new Error('agent insert returned no row'); - return created; + const inserted = await client.query<{ slug: string }>( + `insert into agents (owner_id, slug, name, skills, description, url, operator_id, public) + values ($1, $2, $3, $4, $5, $6, $7, $8) returning slug`, + [ownerId, slug, name, skills, description, url, operatorId, isPublic], + ); + const created = await getAgent(client, inserted.rows[0]?.slug ?? slug, ownerId); + if (created === null) throw new Error('agent insert returned no row'); + await client.query('commit'); + return created; + } catch (error) { + await client.query('rollback'); + throw error; + } finally { + client.release(); + } } function flag(value: unknown): boolean { @@ -331,7 +357,38 @@ function flag(value: unknown): boolean { /** Change what is written about an agent. Only the fields sent are touched. */ export async function updateAgent( - pool: pg.Pool, + pool: AgentQueryable, + ownerId: string, + slug: string, + input: AgentInput, +): Promise { + if (input.operator === undefined || !isPool(pool)) { + return updateAgentOn(pool, ownerId, slug, input); + } + const client = await pool.connect(); + try { + await client.query('begin'); + await client.query('select id from users where id = $1 for update', [ownerId]); + const updated = await updateAgentOn(client, ownerId, slug, input); + await client.query('commit'); + return updated; + } catch (error) { + await client.query('rollback'); + throw error; + } finally { + client.release(); + } +} + +function isPool(pool: AgentQueryable): pool is pg.Pool { + return ( + typeof (pool as pg.Pool).connect === 'function' && + typeof (pool as pg.PoolClient).release !== 'function' + ); +} + +async function updateAgentOn( + pool: AgentQueryable, ownerId: string, slug: string, input: AgentInput, @@ -393,13 +450,24 @@ export async function assignOperator( operatorSlug: string, agentSlugs: string[], ): Promise { - const operator = await ownedRow(pool, operatorSlug, ownerId); - for (const slug of agentSlugs) { - await updateAgent(pool, ownerId, slug, { operator: operator.slug }); + const client = await pool.connect(); + try { + await client.query('begin'); + await client.query('select id from users where id = $1 for update', [ownerId]); + const operator = await ownedRow(client, operatorSlug, ownerId); + for (const slug of agentSlugs) { + await updateAgentOn(client, ownerId, slug, { operator: operator.slug }); + } + const refreshed = await getAgent(client, operator.slug, ownerId); + if (refreshed === null) throw new Error('operator vanished during assignment'); + await client.query('commit'); + return refreshed; + } catch (error) { + await client.query('rollback'); + throw error; + } finally { + client.release(); } - const refreshed = await getAgent(pool, operator.slug, ownerId); - if (refreshed === null) throw new Error('operator vanished during assignment'); - return refreshed; } export async function deleteAgent(pool: pg.Pool, ownerId: string, slug: string): Promise { diff --git a/src/core/agentwriter.ts b/src/core/agentwriter.ts index c9ec337..c15cccd 100644 --- a/src/core/agentwriter.ts +++ b/src/core/agentwriter.ts @@ -10,8 +10,10 @@ * * Two providers, because this is MIT software other people self-host and a * board that only works if you bank with one vendor is not self-hostable. The - * key that is set decides; when neither is set the feature is simply absent - * from the page rather than present and broken. + * keys that are set decide; when neither is set the feature is simply absent + * from the page rather than present and broken. With both set, the second is + * a fallback: a provider that is out of credits or down hands the brief to the + * other one instead of the employer. * * Written against the two REST APIs directly rather than either vendor SDK. * One dependency per provider to fill in a form is a poor trade for a package @@ -36,16 +38,33 @@ export class AgentWriterProblem extends Error {} export type WriterProvider = 'anthropic' | 'openai'; /** - * Which provider this instance can use, or null for none. + * The providers this instance can use, in the order they are tried. * - * Anthropic first when both are set, for no better reason than that a board - * with both keys has to pick one and a coin flip at request time would make - * the same brief produce different listings. + * Anthropic first when both are set, so the same brief goes to the same model + * whenever it can; OpenAI only answers when Anthropic could not. */ +export function writerProviders(config: Config): WriterProvider[] { + const providers: WriterProvider[] = []; + if (config.anthropicApiKey !== null) providers.push('anthropic'); + if (config.openaiApiKey !== null) providers.push('openai'); + return providers; +} + +/** The provider tried first, or null for none. */ export function writerProvider(config: Config): WriterProvider | null { - if (config.anthropicApiKey !== null) return 'anthropic'; - if (config.openaiApiKey !== null) return 'openai'; - return null; + return writerProviders(config)[0] ?? null; +} + +/** + * WRITER_MODEL, for the provider it names. + * + * One setting serves two vendors, so a Claude model name must never be sent to + * OpenAI on the fallback path, nor a GPT name to Anthropic. + */ +function modelFor(provider: WriterProvider, config: Config): string | null { + const model = config.writerModel; + if (model === null) return null; + return model.startsWith('claude') === (provider === 'anthropic') ? model : null; } /** @@ -118,8 +137,8 @@ export async function draftListing( throw new AgentWriterProblem('Say a little more about the role, and I will draft it.'); } - const provider = writerProvider(config); - if (provider === null) { + const providers = writerProviders(config); + if (providers.length === 0) { throw new AgentWriterProblem('This board has no model configured.'); } @@ -133,12 +152,26 @@ export async function draftListing( .filter((line) => line !== null) .join('\n'); - const raw = - provider === 'anthropic' - ? await askAnthropic(prompt, config, fetchImpl) - : await askOpenAi(prompt, config, fetchImpl); - - return fieldsFromModelJson(raw); + // Each provider in turn. A failure on the first is logged for whoever runs + // the board and never shown; the employer only hears about it when every + // provider failed, and then from the last one tried. + let problem: AgentWriterProblem | null = null; + for (const provider of providers) { + try { + const raw = + provider === 'anthropic' + ? await askAnthropic(prompt, config, fetchImpl) + : await askOpenAi(prompt, config, fetchImpl); + return fieldsFromModelJson(raw); + } catch (error) { + if (!(error instanceof AgentWriterProblem)) throw error; + if (problem === null && providers.length > 1) { + console.error(`agent writer: ${provider} failed, trying the next provider: ${error.message}`); + } + problem = error; + } + } + throw problem ?? new AgentWriterProblem('This board has no model configured.'); } /** @@ -161,7 +194,7 @@ async function askAnthropic( 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ - model: config.writerModel ?? 'claude-opus-5', + model: modelFor('anthropic', config) ?? 'claude-opus-5', max_tokens: 4000, output_config: { effort: 'low' }, system: SYSTEM, @@ -207,7 +240,7 @@ async function askOpenAi( authorization: `Bearer ${config.openaiApiKey ?? ''}`, }, body: JSON.stringify({ - model: config.writerModel ?? 'gpt-5.2', + model: modelFor('openai', config) ?? 'gpt-5.2', max_completion_tokens: 4000, response_format: { type: 'json_object' }, messages: [ @@ -239,12 +272,18 @@ async function askOpenAi( * carry account details that do not belong on a public page. */ async function problemFor(response: Response, provider: string): Promise { - // Read and discard, so the connection is not left hanging on a body nobody - // consumed. - await response.text().catch(() => ''); + // Read in full, so the connection is not left hanging, and so an empty + // account can be told apart from a busy one. Both providers say "out of + // credits" with a status that also means something else: OpenAI with a 429, + // Anthropic with a 400. + const body = await response.text().catch(() => ''); + console.error(`agent writer: ${provider} answered ${response.status}: ${body.slice(0, 300)}`); if (response.status === 401 || response.status === 403) { return new AgentWriterProblem(`This board's ${provider} key was rejected.`); } + if (/insufficient_quota|credit_balance|credit balance/i.test(body)) { + return new AgentWriterProblem(`This board's ${provider} account is out of credits.`); + } if (response.status === 429) { return new AgentWriterProblem('The model is rate limited right now. Try again shortly.'); } diff --git a/src/core/applications.ts b/src/core/applications.ts index 951fbb8..654efb6 100644 --- a/src/core/applications.ts +++ b/src/core/applications.ts @@ -21,6 +21,23 @@ import { import { clean } from '../schema/text.ts'; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export const APPLICATIONS_PAGE_SIZE = 100; +const MAX_APPLICATION_OFFSET = 100_000; + +export function applicationPage(params: URLSearchParams): { limit: number; offset: number } { + const integer = (value: string | null, fallback: number, min: number, max: number): number => { + const normalized = value?.trim(); + if (normalized === undefined || !/^[+-]?\d+$/.test(normalized)) return fallback; + const parsed = Number(normalized); + if (!Number.isSafeInteger(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); + }; + + return { + limit: integer(params.get('limit'), APPLICATIONS_PAGE_SIZE, 1, APPLICATIONS_PAGE_SIZE), + offset: integer(params.get('offset'), 0, 0, MAX_APPLICATION_OFFSET), + }; +} interface ApplicationRow { id: string; @@ -70,7 +87,10 @@ export function validateApplication( policy: Job['agentPolicy'], ): { ok: true; value: ValidatedApplication } | { ok: false; problems: ValidationProblem[] } { const problems: ValidationProblem[] = []; - const answers: Record = {}; + // Field names come from an employer's published schema. Use a plain + // dictionary without Object.prototype setters so names such as `__proto__` + // remain ordinary answer keys and survive JSON serialization. + const answers = Object.create(null) as Record; for (const field of schema.fields) { const raw = input[field.name]; @@ -80,7 +100,8 @@ export function validateApplication( const value = clean(raw, field.maxLength ?? 2000, { multiline: field.type === 'textarea' }); if (value === '') { - if (field.required) problems.push({ field: field.name, message: `${field.label} is required.` }); + if (field.required) + problems.push({ field: field.name, message: `${field.label} is required.` }); continue; } @@ -163,12 +184,19 @@ export async function createApplication( pool: pg.Pool, jobId: string, value: ValidatedApplication, - options: { submit?: boolean } = {}, + options: { + submit?: boolean; + userId?: string | null; + resumeMarkdown?: string | null; + resumeTitle?: string | null; + } = {}, ): Promise { const submit = options.submit !== false; const result = await pool.query( - `insert into applications (job_id, answers, agent, status, submitted_at) - values ($1, $2::jsonb, $3::jsonb, $4, case when $5 then now() else null end) + `insert into applications ( + job_id, answers, agent, status, submitted_at, user_id, resume_markdown, resume_title + ) + values ($1, $2::jsonb, $3::jsonb, $4, case when $5 then now() else null end, $6, $7, $8) returning id, job_id, answers, agent, status, created_at, submitted_at`, [ jobId, @@ -176,6 +204,9 @@ export async function createApplication( value.agent === null ? null : JSON.stringify(value.agent), submit ? 'new' : 'draft', submit, + options.userId ?? null, + options.resumeMarkdown ?? null, + options.resumeTitle ?? null, ], ); const row = result.rows[0]; @@ -193,17 +224,36 @@ export async function submitApplication( pool: pg.Pool, id: string, userId: string, -): Promise { +): Promise<'submitted' | 'job_not_open' | 'not_found'> { // Same guard as decideApplication: the id arrives from a URL, and a string // that is not a uuid makes Postgres raise rather than match nothing. - if (!UUID.test(id)) return false; - const result = await pool.query( - `update applications - set status = 'new', submitted_at = now() - where id = $1 and user_id = $2 and submitted_at is null`, + if (!UUID.test(id)) return 'not_found'; + const result = await pool.query<{ outcome: 'submitted' | 'job_not_open' | 'not_found' }>( + `with draft as ( + select a.id, + exists ( + select 1 from jobs j + where j.id = a.job_id and j.status = 'published' + and j.published_at is not null and j.published_at <= now() + and (j.expires_at is null or j.expires_at > now()) + ) as accepting + from applications a + where a.id = $1 and a.user_id = $2 and a.submitted_at is null + ), submitted as ( + update applications a + set status = 'new', submitted_at = now() + from draft d + where a.id = d.id and a.submitted_at is null and d.accepting + returning a.id + ) + select case + when exists (select 1 from submitted) then 'submitted' + when exists (select 1 from draft where not accepting) then 'job_not_open' + else 'not_found' + end as outcome`, [id, userId], ); - return (result.rowCount ?? 0) > 0; + return result.rows[0]?.outcome ?? 'not_found'; } export async function listDraftApplications( @@ -229,18 +279,29 @@ export async function listApplications( pool: pg.Pool, jobId: string, limit = 100, + offset = 0, ): Promise { const result = await pool.query( `select id, job_id, answers, agent, status, created_at, submitted_at, decided_at from applications where job_id = $1 and submitted_at is not null - order by created_at desc - limit $2`, - [jobId, Math.min(500, Math.max(1, limit))], + order by created_at desc, id desc + limit $2 offset $3`, + [jobId, Math.min(500, Math.max(1, limit)), Math.max(0, offset)], ); return result.rows.map((row) => ({ ...toApplication(row), decidedAt: row.decided_at })); } +export async function countApplications(pool: pg.Pool, jobId: string): Promise { + const result = await pool.query<{ total: string | number }>( + `select count(*)::bigint as total + from applications + where job_id = $1 and submitted_at is not null`, + [jobId], + ); + return Number(result.rows[0]?.total ?? 0); +} + /** * Record an employer's decision on one application. * diff --git a/src/core/auth.ts b/src/core/auth.ts index e71af54..a72016e 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -43,6 +43,8 @@ interface UserRow { email: string; name: string | null; is_admin: boolean; + /** True only when this call made the account. */ + created?: boolean; } /** @@ -68,7 +70,7 @@ export async function ensureUser(pool: pg.Pool, email: string, name?: string): P ); const row = created.rows[0]; if (row === undefined) throw new Error('user insert returned no row'); - return row; + return { ...row, created: true }; } export async function createSession( @@ -156,17 +158,32 @@ export function safeRedirect(value: unknown): string | null { return raw; } +/** + * `profile` is what a signup form validated (@profullstack/profile). The + * account does not exist until the link is spent, so it rides on the link and + * is saved by whoever consumes it. Plain sign-in passes nothing. + */ export async function startMagicLink( pool: pg.Pool, email: string, redirect: string | null, + profile: Record | null = null, + carried: { invite?: string | null; ref?: string | null } = {}, ): Promise { const token = mintToken(); const result = await pool.query<{ expires_at: string }>( - `insert into magic_links (token_hash, email, redirect, expires_at) - values ($1, $2, $3, now() + ($4 || ' milliseconds')::interval) + `insert into magic_links (token_hash, email, redirect, expires_at, profile, invite_code, ref_code) + values ($1, $2, $3, now() + ($4 || ' milliseconds')::interval, $5::jsonb, $6, $7) returning expires_at`, - [hashToken(token), email, redirect, String(MAGIC_TTL_MS)], + [ + hashToken(token), + email, + redirect, + String(MAGIC_TTL_MS), + profile === null ? null : JSON.stringify(profile), + carried.invite ?? null, + carried.ref ?? null, + ], ); return { token, expiresAt: result.rows[0]?.expires_at ?? new Date().toISOString() }; } @@ -174,6 +191,11 @@ export async function startMagicLink( export interface ConsumedLink { user: UserRow; redirect: string | null; + /** The signup form's profile fields, when the link was asked for from /signup. */ + profile: Record | null; + /** An invite code or ?ref= code that was carried on the link, if any. */ + invite: string | null; + ref: string | null; } /** @@ -187,17 +209,29 @@ export async function consumeMagicLink( pool: pg.Pool, token: string, ): Promise { - const claimed = await pool.query<{ email: string; redirect: string | null }>( + const claimed = await pool.query<{ + email: string; + redirect: string | null; + profile: Record | null; + invite_code: string | null; + ref_code: string | null; + }>( `update magic_links set used_at = now() where token_hash = $1 and used_at is null and expires_at > now() - returning email, redirect`, + returning email, redirect, profile, invite_code, ref_code`, [hashToken(token)], ); const row = claimed.rows[0]; if (row === undefined) return null; const user = await ensureUser(pool, row.email); - return { user, redirect: row.redirect }; + return { + user, + redirect: row.redirect, + profile: row.profile ?? null, + invite: row.invite_code ?? null, + ref: row.ref_code ?? null, + }; } // --- the device flow ---------------------------------------------------- diff --git a/src/core/browse.ts b/src/core/browse.ts index e6a6249..3258df8 100644 --- a/src/core/browse.ts +++ b/src/core/browse.ts @@ -86,7 +86,8 @@ export function isPrivateAddress(address: string): boolean { // (fe80 through febf, so a prefix test on "fe80:" misses fe90::1), // fec0::/10 is site-local and fc00::/7 is unique-local. const hextet = Number.parseInt(lower.split(':', 1)[0] ?? '', 16); - if ((hextet & 0xffc0) === 0xfe80 || (hextet & 0xffc0) === 0xfec0 || (hextet & 0xfe00) === 0xfc00) return true; + if ((hextet & 0xffc0) === 0xfe80 || (hextet & 0xffc0) === 0xfec0 || (hextet & 0xfe00) === 0xfc00) + return true; if (lower.startsWith('2001:db8:')) return true; // An IPv4 address hidden in an IPv6 one, by whichever prefix carries it: // ::ffff: mapped, the deprecated :: compatible form, the NAT64 well-known @@ -115,8 +116,7 @@ function embeddedIpv4(lower: string): string | 'reserved' | null { const halves = lower.split('::'); if (halves.length > 2) return null; const left = halves[0] === '' ? [] : (halves[0] ?? '').split(':'); - const right = - halves.length === 2 ? (halves[1] === '' ? [] : (halves[1] ?? '').split(':')) : []; + const right = halves.length === 2 ? (halves[1] === '' ? [] : (halves[1] ?? '').split(':')) : []; if (halves.length === 1 && left.length !== 8) return null; const pad = 8 - left.length - right.length; if (halves.length === 2 && pad < 1) return null; @@ -317,7 +317,13 @@ export function htmlToMarkdown(html: string): string { //i.exec(s)?.[0] ?? //i.exec(s)?.[0] ?? s; - s = main.replace(/<(nav|header|footer|aside)[\s\S]*?<\/\1>/gi, ''); + const hasContentLandmark = /<(main|article)\b/i.test(main); + s = main.replace( + hasContentLandmark + ? /<(nav|footer|aside)[\s\S]*?<\/\1>/gi + : /<(nav|header|footer|aside)[\s\S]*?<\/\1>/gi, + '', + ); s = s.replace( /]*>([\s\S]*?)<\/h\1>/gi, (_, level: string, inner: string) => `\n\n${'#'.repeat(Number(level))} ${inline(inner)}\n\n`, @@ -345,7 +351,13 @@ function inline(fragment: string): string { /]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href: string, text: string) => { const label = text.replace(/<[^>]+>/g, '').trim(); - return label && /^https?:\/\//i.test(href) ? `[${label}](${href})` : label; + // A resume often says only "Email" in the visible anchor. Dropping + // its mailto target loses the address before the candidate can edit + // the imported draft or publish a contact channel. + return label && + (/^https?:\/\//i.test(href) || /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/i.test(href)) + ? `[${label}](${href})` + : label; }, ) .replace(/<(strong|b)>([\s\S]*?)<\/\1>/gi, '**$2**') diff --git a/src/core/candidates.ts b/src/core/candidates.ts index b01ba9a..78cae22 100644 --- a/src/core/candidates.ts +++ b/src/core/candidates.ts @@ -10,7 +10,13 @@ import type { Resume } from './resumes.ts'; import type { CandidateSummary } from '../views/candidates.tsx'; import { parseCapacity } from './capacity.ts'; -import { type OpenResume, parseResume, redactContactChannels } from '../markup/resume.ts'; +import { + type OpenResume, + CONTACT_WITHHELD, + hasContactChannel, + parseResume, + redactContactChannels, +} from '../markup/resume.ts'; /** Contact keys that read as a place rather than an address. */ const LOCATION_KEYS = /^(location|based|city|where|region)$/i; @@ -33,12 +39,10 @@ function skillsOf(resume: Resume): string[] { const fromBullets = sections .flatMap((section) => section.markdown.split('\n')) - .map((line) => line.replace(/^\s*[-*+]\s+/, '').trim()) - // A skills bullet is very often "**Languages:** JavaScript, Go", and the - // label is a category rather than a skill. Without dropping it the first - // badge on the card reads "**Languages:** JavaScript". The colon may - // also sit outside the emphasis: "**Languages**: JavaScript, Go". - .map((line) => line.replace(/^\*{0,2}[^*:]{1,40}\*{0,2}\s*:\*{0,2}\s*/, '')) + .map((line) => line.replace(/^\s*(?:[-*+]|\d{1,9}[.)])\s+/, '').trim()) + // A mailto URI may contain several comma-separated recipients. Withhold + // the complete channel before commas can turn its tail into another skill. + .map((line) => redactContactChannels(line).markdown) .filter((line) => line !== '' && !line.startsWith('#')); const flattened = fromBullets.flatMap((line) => @@ -48,9 +52,15 @@ function skillsOf(resume: Resume): string[] { const seen = new Set(); const out: string[] = []; for (const skill of flattened) { + // Check before dropping a category label: `mailto:` is a channel, not a + // label whose removal should leave the recipient on a public badge. + if (skill.includes(CONTACT_WITHHELD) || hasContactChannel(skill)) continue; + // A skills bullet is often "**Languages:** JavaScript, Go". Strip the + // category only after each comma-separated entry has passed the guard. + const withoutLabel = skill.replace(/^\*{0,2}[^*:]{1,40}\*{0,2}\s*:\*{0,2}\s*/, ''); // A sentence is prose that happened to be in the skills section, not a // skill, and a badge is the wrong shape for it. - const cleaned = skill.replace(/\*\*/g, '').replace(/^`|`$/g, '').trim(); + const cleaned = withoutLabel.replace(/\*\*/g, '').replace(/^`|`$/g, '').trim(); if (cleaned === '' || cleaned.length > 40) continue; const key = cleaned.toLowerCase(); if (seen.has(key)) continue; @@ -62,7 +72,8 @@ function skillsOf(resume: Resume): string[] { function locationOf(resume: Resume): string | null { const found = resume.parsed?.contact.find((item) => LOCATION_KEYS.test(item.key)); - return found?.value ?? null; + const value = found?.value; + return value === undefined || hasContactChannel(value) ? null : value; } /** @@ -76,34 +87,38 @@ function locationOf(resume: Resume): string | null { */ const NAME_MAX = 80; -/** - * "Looks like an email address", loosely — the check a public field needs, - * not a validator. Used anywhere directory text could carry a channel. - */ -const HAS_ADDRESS = /[^\s@]+@[^\s@]+\.[^\s@]+/; +/** Remove Markdown emphasis from the heading before using it as plain UI text. */ +function plainName(value: string): string { + let text = value.trim(); + for (;;) { + const cleaned = text.replace(/(\*{1,3}|_{1,3})(?=\S)([\s\S]*?\S)\1/g, '$2'); + if (cleaned === text) return text.trim(); + text = cleaned; + } +} /** * The name shown in the directory. * * A resume with no usable h1 falls back to its title, which its owner wrote - * and which is at least theirs. It never falls back to an email address: - * publishing a resume should not mean publishing an address as the headline. - * An h1 is free text, so "Jane Doe jane@example.com" is a name the parser - * returns without complaint — the address check has to live here, on the + * and which is at least theirs. It never falls back to a contact channel: + * publishing a resume should not mean publishing an address or phone number + * as the headline. An h1 is free text, so the check has to live here, on the * field, the same way it does for the headline. */ export function nameOf(resume: Resume): string { - const parsed = resume.parsed?.name?.trim(); + const sourceName = resume.parsed?.name; + const parsed = sourceName === null || sourceName === undefined ? undefined : plainName(sourceName); if ( parsed !== undefined && parsed !== '' && parsed.length <= NAME_MAX && - !HAS_ADDRESS.test(parsed) + !hasContactChannel(parsed) ) { return parsed; } const title = resume.title.trim(); - return title === '' || title.length > NAME_MAX || HAS_ADDRESS.test(title) + return title === '' || title.length > NAME_MAX || hasContactChannel(title) ? 'Candidate' : title; } @@ -156,15 +171,15 @@ export function resumeForViewer( * A backfill would repair those rows, but it would not stop the next one: any * resume saved by an older build, restored from a backup, or written straight * into the column arrives here the same way. This is a public directory page - * and the field is one line of text, so it is checked on the way out. Cheap, - * and it cannot go stale. + * and the field is one line of text, so it is checked for contact channels on + * the way out. Cheap, and it cannot go stale. */ function headlineOf(resume: Resume): string | null { const headline = resume.parsed?.headline; if (headline === null || headline === undefined) return null; const cleaned = headline.replace(/\*\*|__/g, '').trim(); if (cleaned === '') return null; - return HAS_ADDRESS.test(cleaned) ? null : cleaned; + return hasContactChannel(cleaned) ? null : cleaned; } export function toCandidateSummary(resume: Resume): CandidateSummary { diff --git a/src/core/capacity-alert.ts b/src/core/capacity-alert.ts index 9295d23..c4441a2 100644 --- a/src/core/capacity-alert.ts +++ b/src/core/capacity-alert.ts @@ -52,13 +52,13 @@ interface TargetRow { * * One row per person, not per resume. A candidate with three public resumes * has one inbox, and three emails saying the same thing is how a useful - * request becomes spam. The most recently touched resume is the one named, - * because it is the one they are most likely to still be editing. + * request becomes spam. The most recently touched resume missing capacity is + * the one named, because a newer resume that already states it should not + * hide an older public resume that still leaves employers guessing. */ export async function candidatesMissingCapacity(pool: pg.Pool): Promise { const result = await pool.query( - `select distinct on (r.user_id) - r.user_id, u.email, r.title, r.slug, r.public_slug, r.parsed + `select r.user_id, u.email, r.title, r.slug, r.public_slug, r.parsed from resumes r join users u on u.id = r.user_id where r.visibility = 'public' @@ -68,10 +68,13 @@ export async function candidatesMissingCapacity(pool: pg.Pool): Promise(); for (const row of result.rows) { // The same parser the card uses. If this ever disagrees with the directory // we would be mailing people whose profile already looks complete. if (parseCapacity(row.parsed?.contact ?? []) !== null) continue; + if (included.has(row.user_id)) continue; + included.add(row.user_id); const name = row.parsed?.name?.trim(); out.push({ userId: row.user_id, diff --git a/src/core/coinpay.ts b/src/core/coinpay.ts index 8b3584c..d9846e2 100644 --- a/src/core/coinpay.ts +++ b/src/core/coinpay.ts @@ -7,7 +7,10 @@ * - A person connects their own CoinPay account over OAuth. The board asks for * `wallet:read` and uses the token for exactly one call, `userinfo`, which * returns the wallets they can be paid to. That is all the token can do; - * CoinPay's OAuth has no scope for creating anything. + * CoinPay's OAuth has no scope for creating anything. So the board keeps + * the wallets (addresses, not secrets) and throws the token away in the + * same request: nothing on this server can act as the person on CoinPay. + * "Refresh wallets" is a fresh consent, not a stored token. * - The board has a business key of its own. That is what creates a payment, * with the payee's address as `merchant_wallet_address`, and CoinPay records * who authorised the third-party payout. The board never holds funds. @@ -63,7 +66,17 @@ export interface Payment { txHash: string | null; } -export class CoinPayProblem extends Error {} +export class CoinPayProblem extends Error { + /** CoinPay's HTTP status, or undefined when CoinPay never answered. */ + readonly status: number | undefined; + constructor(message: string, status?: number) { + super(message); + this.status = status; + } +} + +/** The site every payment here is for, as CoinPay records it (`property`). */ +export const PROPERTY = 'agenticjobs.work'; export interface CoinPayClient { readonly config: CoinPayConfig; @@ -83,6 +96,11 @@ export interface CoinPayClient { redirectUrl: string; idempotencyKey: string; metadata: Record; + /** Who is paying: their email and agenticjobs user id. Sent top-level and in metadata. */ + customerEmail?: string | null; + accountId?: string | null; + /** Who is being paid: the payee's agenticjobs user id. */ + payeeId?: string | null; }): Promise; getPayment(id: string): Promise; /** Where a payer goes to pay. */ @@ -180,6 +198,7 @@ export function createCoinPay(config: CoinPayConfig, fetchImpl: Fetch = fetch): // An HTML error page, usually a wrong COINPAY_URL. Say so. throw new CoinPayProblem( `CoinPay answered ${response.status} with something that is not JSON.`, + response.status, ); } return { @@ -210,7 +229,10 @@ export function createCoinPay(config: CoinPayConfig, fetchImpl: Fetch = fetch): }).toString(), }); if (result.status !== 200) { - throw new CoinPayProblem(errorOf(result.body, 'CoinPay refused the token request.')); + throw new CoinPayProblem( + errorOf(result.body, 'CoinPay refused the token request.'), + result.status, + ); } return tokenSet(result.body); } @@ -249,7 +271,10 @@ export function createCoinPay(config: CoinPayConfig, fetchImpl: Fetch = fetch): headers: { authorization: `Bearer ${accessToken}` }, }); if (result.status !== 200) { - throw new CoinPayProblem(errorOf(result.body, 'CoinPay would not say who that token is.')); + throw new CoinPayProblem( + errorOf(result.body, 'CoinPay would not say who that token is.'), + result.status, + ); } const sub = str(result.body['sub']); if (sub === null) throw new CoinPayProblem('CoinPay returned a user with no id.'); @@ -276,7 +301,18 @@ export function createCoinPay(config: CoinPayConfig, fetchImpl: Fetch = fetch): merchant_wallet_address: input.payeeAddress, description: input.description, redirect_url: input.redirectUrl, - metadata: { ...input.metadata, idempotency_key: input.idempotencyKey }, + // Optional on /payments/create: a malformed one is dropped by CoinPay, never a 400. + ...(input.customerEmail ? { customer_email: input.customerEmail } : {}), + ...(input.accountId ? { account_id: input.accountId } : {}), + property: PROPERTY, + metadata: { + ...input.metadata, + property: PROPERTY, + ...(input.accountId ? { account_id: input.accountId } : {}), + ...(input.customerEmail ? { customer_email: input.customerEmail } : {}), + ...(input.payeeId ? { payee_id: input.payeeId } : {}), + idempotency_key: input.idempotencyKey, + }, }), }); if (result.status !== 200 && result.status !== 201) { @@ -339,7 +375,9 @@ export interface Account { wallets: Wallet[]; connectedAt: string; updatedAt: string; - /** False when the token cannot read wallets: the person has to reconnect. */ + /** When `wallets` was last read from CoinPay (connect, reconnect or the legacy sweep). */ + walletsSyncedAt: string | null; + /** False when the grant could not read wallets: the person has to reconnect. */ usable: boolean; } @@ -348,11 +386,13 @@ interface AccountRow { coinpay_sub: string; email: string | null; name: string | null; - access_token: string; + /** Legacy only (before 0026): plaintext tokens, migrated away by `sweepLegacyTokens`. */ + access_token: string | null; refresh_token: string | null; scope: string; - expires_at: string; + expires_at: string | null; wallets: unknown; + wallets_synced_at: string | null; connected_at: string; updated_at: string; } @@ -368,7 +408,10 @@ function toAccount(row: AccountRow): Account { wallets: normaliseWallets(row.wallets), connectedAt: row.connected_at, updatedAt: row.updated_at, - usable: row.access_token !== '' && hasScope(scope), + walletsSyncedAt: row.wallets_synced_at, + // The wallets were read under a grant that had wallet:read. No token is + // needed to pay to them, so none is kept. + usable: hasScope(scope), }; } @@ -418,6 +461,12 @@ export async function finishConnect( coinpay: CoinPayClient, input: { state: string; code: string; redirectUri: string }, ): Promise<{ userId: string; redirect: string | null; account: Account } | string> { + // The state travels into a SQL parameter, and a NUL byte is not text: + // PostgreSQL refuses it outright, so `?state=%00` on + // GET /api/v1/coinpay/callback answered 500 where every state that matches + // no pending flow answers "expired". Drop the byte; a state that cleans + // away to nothing matches nothing. + const state = input.state.replace(/\0/g, '').trim(); const claimed = await pool.query<{ user_id: string; code_verifier: string; @@ -425,7 +474,7 @@ export async function finishConnect( }>( `delete from coinpay_oauth_states where state = $1 and expires_at > now() returning user_id, code_verifier, redirect`, - [input.state], + [state], ); const pending = claimed.rows[0]; if (pending === undefined) return 'That connection attempt has expired. Start again.'; @@ -457,26 +506,25 @@ export async function finishConnect( return 'That CoinPay account is already connected to a different account on this board. Disconnect it there first.'; } + // The wallets are what the board needs; the tokens are not stored at all. + // A reconnect over a legacy row clears its old tokens in the same write. const saved = await pool.query( `insert into coinpay_accounts - (user_id, coinpay_sub, email, name, access_token, refresh_token, scope, expires_at, wallets) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb) + (user_id, coinpay_sub, email, name, access_token, refresh_token, scope, expires_at, + wallets, wallets_synced_at) + values ($1, $2, $3, $4, null, null, $5, null, $6::jsonb, now()) on conflict (user_id) do update set coinpay_sub = excluded.coinpay_sub, email = excluded.email, name = excluded.name, - access_token = excluded.access_token, - refresh_token = coalesce(excluded.refresh_token, coinpay_accounts.refresh_token), - scope = excluded.scope, expires_at = excluded.expires_at, wallets = excluded.wallets, - updated_at = now() + access_token = null, refresh_token = null, expires_at = null, + scope = excluded.scope, wallets = excluded.wallets, + wallets_synced_at = now(), updated_at = now() returning *`, [ pending.user_id, info.sub, info.email, info.name, - tokens.accessToken, - tokens.refreshToken, tokens.scope.join(' '), - tokens.expiresAt, JSON.stringify(info.wallets), ], ); @@ -487,65 +535,130 @@ export async function finishConnect( }; } +// --- legacy tokens --------------------------------------------------------- + /** - * A token that works right now, refreshing if it is about to stop. Null when - * the person has to reconnect, which the caller should say plainly. + * Before 0026 the board kept each person's CoinPay access and refresh tokens + * in plaintext, readable by anyone with the database or root on the box. The + * only thing they were ever used for was re-reading wallets, so each legacy + * row is converted once: one refresh, one `userinfo`, the wallets written and + * every token column nulled in the same update. + * + * - CoinPay refuses the refresh or the read (invalid_grant, 401, 403): the + * tokens are deleted anyway. The wallets read at connect time stay, so + * invoices keep working; "Refresh wallets" is a reconnect. + * - CoinPay is down or answers 5xx: nothing changes, the next sweep retries. + * - `legacy: false` (LEGACY_KEY_MIGRATION=off): legacy tokens are never sent + * anywhere again; the sweep just deletes them. */ -async function freshToken( +export type LegacyOutcome = 'migrated' | 'refused' | 'deferred' | 'dropped'; + +export interface LegacySweep { + migrated: number; + refused: number; + deferred: number; + dropped: number; + /** Rows still holding a token after this sweep. */ + remaining: number; +} + +const DROP_TOKENS = `access_token = null, refresh_token = null, expires_at = null`; + +function refusedByCoinPay(error: unknown): boolean { + return ( + error instanceof CoinPayProblem && + error.status !== undefined && + error.status >= 400 && + error.status < 500 + ); +} + +export async function migrateLegacyToken( pool: pg.Pool, - coinpay: CoinPayClient, - row: AccountRow, -): Promise { - if (!hasScope(row.scope)) return null; - if (new Date(row.expires_at).getTime() - Date.now() > EXPIRY_BUFFER_MS) return row.access_token; - if (row.refresh_token === null) return null; + coinpay: CoinPayClient | null, + row: Pick, + options: { legacy: boolean }, +): Promise { + if (!options.legacy || coinpay === null) { + await pool.query( + `update coinpay_accounts set ${DROP_TOKENS}, updated_at = now() where user_id = $1`, + [row.user_id], + ); + return 'dropped'; + } try { - const tokens = await coinpay.refresh(row.refresh_token); + let access = row.access_token; + let scope: string[] | null = null; + const expired = + row.expires_at === null || + new Date(row.expires_at).getTime() - Date.now() <= EXPIRY_BUFFER_MS; + if (access === null || access === '' || expired) { + if (row.refresh_token === null || row.refresh_token === '') { + throw new CoinPayProblem('No refresh token.', 400); + } + const tokens = await coinpay.refresh(row.refresh_token); + access = tokens.accessToken; + scope = tokens.scope; + } + const info = await coinpay.userinfo(access); + const usable = scope === null || hasScope(scope); await pool.query( - `update coinpay_accounts set access_token = $2, refresh_token = coalesce($3, refresh_token), - scope = $4, expires_at = $5, updated_at = now() where user_id = $1`, + `update coinpay_accounts set ${DROP_TOKENS}, + wallets = case when $2 then $3::jsonb else wallets end, + wallets_synced_at = case when $2 then now() else wallets_synced_at end, + scope = coalesce($4, scope), + email = coalesce($5, email), name = coalesce($6, name), updated_at = now() + where user_id = $1`, [ row.user_id, - tokens.accessToken, - tokens.refreshToken, - tokens.scope.join(' '), - tokens.expiresAt, + usable, + JSON.stringify(info.wallets), + scope === null ? null : scope.join(' '), + info.email, + info.name, ], ); - return hasScope(tokens.scope) ? tokens.accessToken : null; - } catch { - return null; + return 'migrated'; + } catch (error) { + if (refusedByCoinPay(error)) { + await pool.query( + `update coinpay_accounts set ${DROP_TOKENS}, updated_at = now() where user_id = $1`, + [row.user_id], + ); + return 'refused'; + } + if (error instanceof CoinPayProblem) return 'deferred'; + throw error; } } -/** - * Re-read the wallets from CoinPay. Returns the account as it now stands, or - * a sentence when the connection no longer works. - */ -export async function refreshWallets( +/** How many rows still hold a CoinPay token. Zero once the migration is done. */ +export async function legacyTokenCount(pool: pg.Pool): Promise { + const result = await pool.query<{ n: number }>( + `select count(*)::int as n from coinpay_accounts + where access_token is not null or refresh_token is not null`, + ); + return result.rows[0]?.n ?? 0; +} + +/** Convert every legacy row. Safe to run any number of times. */ +export async function sweepLegacyTokens( pool: pg.Pool, - coinpay: CoinPayClient, - userId: string, -): Promise { - const result = await pool.query(`select * from coinpay_accounts where user_id = $1`, [ - userId, - ]); - const row = result.rows[0]; - if (row === undefined) return 'No CoinPay account is connected.'; - const token = await freshToken(pool, coinpay, row); - if (token === null) return 'The CoinPay connection has lapsed. Reconnect it.'; - try { - const info = await coinpay.userinfo(token); - const saved = await pool.query( - `update coinpay_accounts set wallets = $2::jsonb, email = $3, name = $4, updated_at = now() - where user_id = $1 returning *`, - [userId, JSON.stringify(info.wallets), info.email, info.name], - ); - return toAccount(saved.rows[0]!); - } catch (error) { - if (error instanceof CoinPayProblem) return error.message; - throw error; + coinpay: CoinPayClient | null, + options: { legacy: boolean }, +): Promise { + const rows = await pool.query< + Pick + >( + `select user_id, access_token, refresh_token, expires_at from coinpay_accounts + where access_token is not null or refresh_token is not null`, + ); + const out: LegacySweep = { migrated: 0, refused: 0, deferred: 0, dropped: 0, remaining: 0 }; + for (const row of rows.rows) { + out[await migrateLegacyToken(pool, coinpay, row, options)] += 1; } + out.remaining = await legacyTokenCount(pool); + return out; } /** diff --git a/src/core/credits.ts b/src/core/credits.ts new file mode 100644 index 0000000..e1023e2 --- /dev/null +++ b/src/core/credits.ts @@ -0,0 +1,390 @@ +/** + * Credit codes and invites. + * + * Two house packages over this board's Postgres, and nothing else: + * + * - @profullstack/redeem-credits mints scarce codes (stored only as hashes) + * that grant credit, in US cents, to whoever redeems them. The board has no + * balance of its own, so the package's ledger is the balance. + * - @profullstack/affiliates gives every account three single-use invites and + * one open ?ref= code, and records who brought whom. + * + * An accepted invite pays a small credit to both people, but only after the + * checks below: the invitee's address was confirmed (the magic link was spent), + * the account is new, the two are not the same mailbox, the inviter's account + * is old enough, and the inviter is under a monthly cap. + */ + +import type pg from 'pg'; +import { + Credits, + postgresStore as creditStore, + normalizeCode as normalizeCreditCode, + type MintedCode, + type RedeemResult, +} from '@profullstack/redeem-credits'; +import { + Affiliates, + postgresStore as affiliateStore, + normalizeCode as normalizeInviteCode, + type Invite, + type RedeemInviteResult, +} from '@profullstack/affiliates'; +import type { Config } from '../config.ts'; + +export { REF_COOKIE } from '@profullstack/affiliates'; + +/** Credit is kept in US cents. */ +export const CREDIT_UNIT = 'usd_cents'; +/** Invites per account. Scarce on purpose: an invite says someone wanted you here. */ +export const INVITE_QUOTA = 3; +/** Where an /i/ visit is remembered until the person signs up. */ +export const INVITE_COOKIE = 'aj_invite'; +/** Bonuses one inviter can earn in 30 days, whatever their quota is raised to. */ +export const INVITER_MONTHLY_CAP = 10; + +const DEFAULT_BONUS_CENTS = 100; +const DEFAULT_INVITER_MIN_AGE_HOURS = 24; + +interface Services { + credits: Credits; + affiliates: Affiliates; +} + +const services = new WeakMap(); + +/** One pair of services per pool, built on first use. */ +export function creditServices(pool: pg.Pool): Services { + let found = services.get(pool); + if (found === undefined) { + found = { + credits: new Credits(creditStore(pool)), + affiliates: new Affiliates(affiliateStore(pool), { defaultQuota: INVITE_QUOTA }), + }; + services.set(pool, found); + } + return found; +} + +// --- input --------------------------------------------------------------- + +/** + * A credit or invite code as typed, or null when it cannot be one. The + * packages forgive case, spaces and dashes; this only keeps junk (and + * anything long enough to be an attack on a log line) out. + */ +export function cleanCode(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (trimmed === '' || trimmed.length > 48) return null; + if (!/^[A-Za-z0-9 _-]+$/.test(trimmed)) return null; + return trimmed; +} + +/** An affiliate code from ?ref= or the cookie: short, letters and digits. */ +export function cleanRef(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return /^[A-Za-z0-9]{4,32}$/.test(trimmed) ? trimmed.toUpperCase() : null; +} + +// --- credit ---------------------------------------------------------------- + +export function formatCents(cents: number): string { + const sign = cents < 0 ? '-' : ''; + const abs = Math.abs(Math.round(cents)); + return `${sign}$${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`; +} + +export const REDEEM_MESSAGES: Record['reason'], string> = { + invalid: 'That code does not exist. Check it and try again.', + expired: 'That code has expired.', + used_up: 'That code has already been used.', + already_redeemed: 'You have already redeemed that code.', + revoked: 'That code has been withdrawn.', +}; + +export interface CreditEntry { + cents: number; + kind: 'grant' | 'spend'; + source: string; + at: string; +} + +export interface CreditSummary { + unit: typeof CREDIT_UNIT; + balanceCents: number; + balance: string; + history: CreditEntry[]; +} + +/** What a ledger source means to the person reading it. */ +export function describeSource(source: string): string { + if (source.startsWith('code:')) return 'Credit code'; + if (source.startsWith('invite:')) return 'Invite bonus'; + if (source === 'admin') return 'Granted by the board'; + return source; +} + +export async function creditSummary(pool: pg.Pool, userId: string): Promise { + const { credits } = creditServices(pool); + const [balanceCents, history] = await Promise.all([ + credits.balance(userId, CREDIT_UNIT), + credits.history(userId, CREDIT_UNIT), + ]); + return { + unit: CREDIT_UNIT, + balanceCents, + balance: formatCents(balanceCents), + history: history + .slice(-20) + .reverse() + .map((entry) => ({ + cents: entry.amount, + kind: entry.kind, + source: describeSource(entry.source), + at: new Date(entry.at).toISOString(), + })), + }; +} + +export type RedeemOutcome = + | { ok: true; cents: number; balanceCents: number } + | { ok: false; reason: Exclude['reason']; message: string }; + +export async function redeemCreditCode( + pool: pg.Pool, + userId: string, + code: string, +): Promise { + const { credits } = creditServices(pool); + const result = await credits.redeem(code, userId); + if (!result.ok) + return { ok: false, reason: result.reason, message: REDEEM_MESSAGES[result.reason] }; + return { + ok: true, + cents: result.grant.unit === CREDIT_UNIT ? result.grant.amount : 0, + balanceCents: await credits.balance(userId, CREDIT_UNIT), + }; +} + +export interface MintRequest { + cents: number; + label: string; + maxRedemptions?: number; + expiresInDays?: number | null; + createdBy?: string; +} + +/** An operator mints a code. The plaintext is in the result and nowhere else. */ +export async function mintCreditCode(pool: pg.Pool, request: MintRequest): Promise { + if (!Number.isInteger(request.cents) || request.cents <= 0 || request.cents > 1_000_000) { + throw new Error('cents must be a whole number from 1 to 1000000'); + } + const max = request.maxRedemptions ?? 1; + if (!Number.isInteger(max) || max < 1 || max > 10_000) { + throw new Error('maxRedemptions must be a whole number from 1 to 10000'); + } + const days = request.expiresInDays ?? null; + return creditServices(pool).credits.mint({ + grant: { unit: CREDIT_UNIT, amount: request.cents }, + label: request.label.trim().slice(0, 120) || 'agenticjobs credit', + maxRedemptions: max, + expiresAt: days === null ? null : Date.now() + days * 86_400_000, + prefix: 'AJ', + ...(request.createdBy === undefined ? {} : { createdBy: request.createdBy }), + }); +} + +export { normalizeCreditCode }; + +// --- invites --------------------------------------------------------------- + +export interface InviteView { + id: string; + note: string; + createdAt: string; + expiresAt: string | null; + used: boolean; + usedAt: string | null; + revoked: boolean; +} + +export interface InviteSummary { + quota: { total: number; used: number; left: number }; + invites: InviteView[]; + affiliateCode: string; + affiliateUrl: string; + invitedCount: number; +} + +function inviteView(invite: Omit & { revokedAt?: number | null }): InviteView { + return { + id: invite.id, + note: invite.note, + createdAt: new Date(invite.createdAt).toISOString(), + expiresAt: invite.expiresAt === null ? null : new Date(invite.expiresAt).toISOString(), + used: invite.usedBy !== null, + usedAt: invite.usedAt === null ? null : new Date(invite.usedAt).toISOString(), + revoked: (invite.revokedAt ?? null) !== null, + }; +} + +export function inviteUrl(origin: string, code: string): string { + return `${origin}/i/${encodeURIComponent(code)}`; +} + +export async function inviteSummary( + pool: pg.Pool, + userId: string, + origin: string, +): Promise { + const { affiliates } = creditServices(pool); + const [quota, invites, affiliateCode, invitees] = await Promise.all([ + affiliates.quota(userId), + affiliates.invites(userId), + affiliates.affiliateCode(userId), + affiliates.invitees(userId), + ]); + return { + quota, + invites: invites.map(inviteView), + affiliateCode, + affiliateUrl: `${origin}/?ref=${affiliateCode}`, + invitedCount: invitees.length, + }; +} + +export type CreateInviteOutcome = + | { ok: true; code: string; url: string; invite: InviteView; quota: InviteSummary['quota'] } + | { ok: false; reason: 'no_invites_left' }; + +export async function createInvite( + pool: pg.Pool, + userId: string, + note: string, + origin: string, +): Promise { + const { affiliates } = creditServices(pool); + try { + const invite = await affiliates.createInvite(userId, note.trim().slice(0, 120)); + return { + ok: true, + code: invite.code, + url: inviteUrl(origin, invite.code), + invite: inviteView(invite), + quota: await affiliates.quota(userId), + }; + } catch (error) { + if (error instanceof Error && /no invites left/i.test(error.message)) { + return { ok: false, reason: 'no_invites_left' }; + } + throw error; + } +} + +/** The canonical spelling, for a cookie and a URL. Null when it cannot be an invite. */ +export function canonicalInvite(value: unknown): string | null { + const cleaned = cleanCode(value); + if (cleaned === null) return null; + const normal = normalizeInviteCode(cleaned); + return /^INV-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/.test(normal) ? normal : null; +} + +/** + * One mailbox under two spellings: case, a +tag, and Gmail's dots. Someone + * inviting themselves to collect both halves of a bonus is the abuse this + * catches; a second domain is not caught and is what the cap is for. + */ +export function sameMailbox(a: string, b: string): boolean { + const canonical = (email: string): string => { + const [rawLocal = '', rawDomain = ''] = email.trim().toLowerCase().split('@'); + let domain = rawDomain; + let local = rawLocal.split('+')[0] ?? ''; + if (domain === 'googlemail.com') domain = 'gmail.com'; + if (domain === 'gmail.com') local = local.replaceAll('.', ''); + return `${local}@${domain}`; + }; + return canonical(a) === canonical(b); +} + +export type BonusStatus = + 'granted' | 'disabled' | 'same_mailbox' | 'inviter_too_new' | 'inviter_cap' | 'already'; + +export interface AcceptOutcome { + invite: RedeemInviteResult; + bonus: BonusStatus | null; + cents: number; +} + +/** + * Accept an invite for an account that was created by this sign-in. + * + * The caller is the magic-link callback, so the invitee's address is + * confirmed by construction. The invite is spent whatever the bonus decides: + * who invited whom is recorded even when nothing is paid for it. + */ +export async function acceptInvite( + pool: pg.Pool, + config: Pick, + code: string, + invitee: { id: string; email: string }, +): Promise { + const { affiliates, credits } = creditServices(pool); + const invite = await affiliates.redeemInvite(code, invitee.id); + if (!invite.ok) return { invite, bonus: null, cents: 0 }; + + const bonusCents = config.invites?.bonusCents ?? DEFAULT_BONUS_CENTS; + const minAgeHours = config.invites?.inviterMinAgeHours ?? DEFAULT_INVITER_MIN_AGE_HOURS; + + const inviterRow = await pool.query<{ email: string; age_hours: number; recent: number }>( + `select u.email, + extract(epoch from (now() - u.created_at)) / 3600 as age_hours, + (select count(*)::int from invite_bonuses b + where b.user_id = u.id and b.role = 'inviter' and b.status = 'granted' + and b.created_at > now() - interval '30 days') as recent + from users u where u.id = $1`, + [invite.inviterId], + ); + const inviter = inviterRow.rows[0]; + + let status: BonusStatus = 'granted'; + if (bonusCents <= 0) status = 'disabled'; + else if (inviter === undefined) status = 'disabled'; + else if (sameMailbox(inviter.email, invitee.email)) status = 'same_mailbox'; + else if (Number(inviter.age_hours) < minAgeHours) status = 'inviter_too_new'; + else if (inviter.recent >= INVITER_MONTHLY_CAP) status = 'inviter_cap'; + + const cents = status === 'granted' ? bonusCents : 0; + const pairs: [string, 'inviter' | 'invitee'][] = [ + [invitee.id, 'invitee'], + ...(inviter === undefined ? [] : ([[invite.inviterId, 'inviter']] as [string, 'inviter'][])), + ]; + let paid = 0; + for (const [userId, role] of pairs) { + // The row is claimed first, so a retry of this callback never pays twice. + const claimed = await pool.query( + `insert into invite_bonuses (invite_id, user_id, role, cents, status) + values ($1, $2, $3, $4, $5) + on conflict do nothing returning invite_id`, + [invite.inviteId, userId, role, cents, status === 'granted' ? 'pending' : status], + ); + if (claimed.rowCount === 0 || status !== 'granted') continue; + await credits.grant(userId, { unit: CREDIT_UNIT, amount: cents }, `invite:${invite.inviteId}`); + await pool.query( + `update invite_bonuses set status = 'granted' where invite_id = $1 and user_id = $2`, + [invite.inviteId, userId], + ); + if (role === 'invitee') paid = cents; + } + return { invite, bonus: status, cents: paid }; +} + +/** First touch: an account already attributed keeps its first affiliate. */ +export async function attributeRef( + pool: pg.Pool, + ref: string, + userId: string, +): Promise<{ ok: boolean; affiliateId?: string }> { + return creditServices(pool).affiliates.attribute(ref, userId); +} diff --git a/src/core/import-job.ts b/src/core/import-job.ts index 73c46fd..e0d876f 100644 --- a/src/core/import-job.ts +++ b/src/core/import-job.ts @@ -54,7 +54,7 @@ function stripTags(html: string): string { .replace(/])(?:[^>"']|"[^"]*"|'[^']*')*>/gi, '\n') .replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n') .replace(/]*>/gi, '- ') - .replace(/<[^>]+>/g, ' '); + .replace(/<\/?[a-z][^<>]*>/gi, ' '); } /** The five named entities plus numeric ones; enough for text nobody will re-parse. */ diff --git a/src/core/import.ts b/src/core/import.ts index 6f2dfef..6a09a99 100644 --- a/src/core/import.ts +++ b/src/core/import.ts @@ -23,7 +23,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; -import { readZipEntry, ZipProblem } from './zip.ts'; +import { hasZipEntry, readZipEntry, ZipProblem } from './zip.ts'; const run = promisify(execFile); @@ -78,8 +78,17 @@ function classify(extension: string, mime: string, bytes: Buffer): Kind { // The magic bytes win over the extension. A resume renamed from .pdf to // .docx is a mistake someone makes once, and the file itself is not lying. if (bytes.subarray(0, 5).toString('latin1') === '%PDF-') return 'pdf'; - if (bytes.subarray(0, 2).toString('latin1') === 'PK' && (extension === 'docx' || mime.includes('wordprocessingml'))) { - return 'docx'; + if (bytes.subarray(0, 2).toString('latin1') === 'PK') { + if (extension === 'docx' || mime.includes('wordprocessingml')) return 'docx'; + // Word documents are ZIP archives. A renamed .docx still identifies + // itself through its document entry, even when the extension and browser + // MIME type describe something else. + try { + if (hasZipEntry(bytes, 'word/document.xml')) return 'docx'; + } catch { + // An unrelated or malformed ZIP is not a Word document; keep checking + // the filename and MIME type below for the useful import error. + } } if (['md', 'markdown', 'mdown', 'mkd'].includes(extension)) return 'markdown'; if (extension === 'txt' || mime === 'text/plain') return 'text'; diff --git a/src/core/inbox.ts b/src/core/inbox.ts index 747512f..dd27ea5 100644 --- a/src/core/inbox.ts +++ b/src/core/inbox.ts @@ -193,47 +193,53 @@ export async function startThread( } const jobId = input.jobId ?? null; - - // Find the existing conversation between these parties about this listing. - // "These parties" includes which identity the sender is using: a member of - // two employers has one row per thread, and `me.org_id` says whether that - // row is them personally or them speaking for one employer. Matching only - // on `me.user_id` would deliver a message written as Org B into a thread - // with Org A, shown to the other side as Org A and readable by Org A's - // members - and a personal message into an employer's thread the same way. - const existing = await pool.query<{ id: string }>( - `select t.id from threads t - where t.job_id is not distinct from $3 - and exists (select 1 from thread_participants me - where me.thread_id = t.id and me.user_id = $1 - and me.org_id is not distinct from $4) - and exists (select 1 from thread_participants them - where them.thread_id = t.id - and ${to.kind === 'candidate' ? 'them.user_id = $2 and them.org_id is null' : 'them.org_id = $2'}) - order by t.last_message_at desc limit 1`, - [senderId, to.kind === 'candidate' ? to.userId : to.orgId, jobId, asOrg], - ); - const found = existing.rows[0]; - if (found !== undefined) { - const message = await sendMessage(pool, found.id, senderId, body); - if (typeof message === 'string') return message; - return { threadId: found.id, messageId: message.id, created: false }; - } - - // The limit is on *new* conversations: replying in one you already have is - // never what a spammer is doing. - const recent = await pool.query<{ count: number }>( - `select count(*)::int as count from threads - where created_by = $1 and created_at > now() - interval '1 day'`, - [senderId], - ); - if ((recent.rows[0]?.count ?? 0) >= DAILY_THREADS) { - return `That is ${DAILY_THREADS} new conversations today. Reply in one you have, or come back tomorrow.`; - } - + const senderParty = asOrg === null ? `candidate:${senderId}` : `employer:${asOrg}`; + const recipientParty = + to.kind === 'candidate' ? `candidate:${to.userId}` : `employer:${to.orgId}`; + const conversationKey = JSON.stringify([jobId, ...[senderParty, recipientParty].sort()]); const client = await pool.connect(); try { await client.query('begin'); + // Two first messages can otherwise both miss the lookup below and create + // duplicate threads. Lock the conversation identity before checking it. + await client.query('select pg_advisory_xact_lock(hashtextextended($1, 0))', [conversationKey]); + + // Find the existing conversation between these parties about this listing. + // "These parties" includes which identity the sender is using: a member of + // two employers has one row per thread, and `me.org_id` says whether that + // person is themself or speaking for one employer. + const existing = await client.query<{ id: string }>( + `select t.id from threads t + where t.job_id is not distinct from $3 + and exists (select 1 from thread_participants me + where me.thread_id = t.id and me.user_id = $1 + and me.org_id is not distinct from $4) + and exists (select 1 from thread_participants them + where them.thread_id = t.id + and ${to.kind === 'candidate' ? 'them.user_id = $2 and them.org_id is null' : 'them.org_id = $2'}) + order by t.last_message_at desc limit 1`, + [senderId, to.kind === 'candidate' ? to.userId : to.orgId, jobId, asOrg], + ); + const found = existing.rows[0]; + if (found !== undefined) { + await client.query('commit'); + const message = await sendMessage(pool, found.id, senderId, body); + if (typeof message === 'string') return message; + return { threadId: found.id, messageId: message.id, created: false }; + } + + // The limit is on new conversations: replying in one you have is never + // what a spammer is doing. + const recent = await client.query<{ count: number }>( + `select count(*)::int as count from threads + where created_by = $1 and created_at > now() - interval '1 day'`, + [senderId], + ); + if ((recent.rows[0]?.count ?? 0) >= DAILY_THREADS) { + await client.query('rollback'); + return `That is ${DAILY_THREADS} new conversations today. Reply in one you have, or come back tomorrow.`; + } + const thread = await client.query<{ id: string }>( `insert into threads (subject, job_id, created_by) values ($1, $2, $3) returning id`, [subject === '' ? preview(body).slice(0, SUBJECT_MAX) : subject, jobId, senderId], @@ -408,7 +414,12 @@ export async function getThread( sender_id: string; }>( `select id, kind, body, invoice_id, created_at, sender_id - from messages where thread_id = $1 order by created_at asc limit 500`, + from ( + select id, kind, body, invoice_id, created_at, sender_id + from messages where thread_id = $1 + order by created_at desc, id desc limit 500 + ) recent + order by created_at asc, id asc`, [threadId], ); @@ -541,8 +552,13 @@ export async function counterpartyFrom( pool: pg.Pool, input: { candidate?: string | null; employer?: string | null }, ): Promise { - const candidate = (input.candidate ?? '').trim(); - const employer = (input.employer ?? '').trim(); + // Both slugs travel into SQL parameters, and a NUL byte is not text: + // PostgreSQL refuses it outright, so a body carrying one answered 500 + // where every unknown slug answers "nobody here". Drop the byte the way + // parseQuery drops it from a search. + const candidate = (input.candidate ?? '').replace(/\0/g, '').trim(); + const employer = (input.employer ?? '').replace(/\0/g, '').trim(); + if (candidate !== '' && employer !== '') return null; if (candidate !== '') { const result = await pool.query<{ user_id: string }>( `select user_id from resumes diff --git a/src/core/invoices.ts b/src/core/invoices.ts index c8ed7df..0e26945 100644 --- a/src/core/invoices.ts +++ b/src/core/invoices.ts @@ -307,6 +307,11 @@ export async function requestPayment( return synced; } + const payer = await pool.query<{ email: string }>('select email from users where id = $1', [ + input.payerId, + ]); + const payerEmail = payer.rows[0]?.email ?? null; + let minted; try { minted = await coinpay.createPayment({ @@ -324,6 +329,9 @@ export async function requestPayment( thread_id: current.threadId, payer_id: input.payerId, }, + customerEmail: payerEmail, + accountId: input.payerId, + payeeId: current.payee.id, }); } catch (error) { if (error instanceof CoinPayProblem) return error.message; @@ -419,15 +427,43 @@ export async function markPaid( * to the next page load, which clears it the same way and does not depend on * a delivery that may never come. */ +export function readWebhook(payload: Record): { + event: string; + paymentId: string; + status: string; + txHash: string | null; + metadata: Record; +} { + // CoinPay nests the payment under `data`; the top-level `id` is the EVENT id, + // never the payment's. Read `data` first, the old flat shape as a fallback. + const nested = payload['data']; + const data = + typeof nested === 'object' && nested !== null ? (nested as Record) : payload; + const s = (v: unknown) => (typeof v === 'string' ? v : ''); + const event = s(payload['type']) || s(payload['event']); + const paymentId = s(data['payment_id']) || (data === payload ? '' : s(data['id'])); + const meta = data['metadata'] ?? payload['metadata']; + return { + event, + paymentId, + status: s(data['status']).toLowerCase(), + txHash: s(data['tx_hash']) || null, + metadata: typeof meta === 'object' && meta !== null ? (meta as Record) : {}, + }; +} + export async function applyWebhook( pool: pg.Pool, payload: Record, ): Promise<'paid' | 'ignored' | 'unknown'> { - const event = typeof payload['event'] === 'string' ? payload['event'] : ''; - const paymentId = typeof payload['payment_id'] === 'string' ? payload['payment_id'] : ''; + const { event, paymentId, status, txHash } = readWebhook(payload); if (paymentId === '') return 'ignored'; - if (event !== 'payment.confirmed' && event !== 'payment.forwarded') return 'ignored'; - const txHash = typeof payload['tx_hash'] === 'string' ? payload['tx_hash'] : null; + const settled = + event === 'payment.confirmed' || + event === 'payment.forwarded' || + status === 'confirmed' || + status === 'forwarded'; + if (!settled) return 'ignored'; const paid = await markPaid(pool, { paymentId, txHash }); return paid ? 'paid' : 'unknown'; } diff --git a/src/core/jobs.ts b/src/core/jobs.ts index 08a1309..4a57361 100644 --- a/src/core/jobs.ts +++ b/src/core/jobs.ts @@ -264,9 +264,16 @@ function conditions( } if (query.tags.length > 0) { params.push(query.tags); - // Matching either list means "python" finds a job that called it a tag and - // one that called it part of the stack. - where.push(`(j.tags && $${params.length}::text[] or j.stack && $${params.length}::text[])`); + // Every requested skill narrows the search, whether a listing put it in + // tags or stack. A single array-overlap test made `rust,typescript` mean + // either skill and returned unrelated jobs from a search that looked exact. + where.push( + `((j.tags && $${params.length}::text[]) or (j.stack && $${params.length}::text[])) + and not exists ( + select 1 from unnest($${params.length}::text[]) as requested(tag) + where not (requested.tag = any(j.tags) or requested.tag = any(j.stack)) + )`, + ); } if (query.salaryMin !== null) { params.push(query.salaryMin); @@ -302,7 +309,9 @@ export async function searchJobs( const includeUnpublished = options.includeUnpublished === true; const { sql: whereSql, params } = conditions(query, includeUnpublished); - const counted = await pool.query<{ total: number }>( + // PostgreSQL's int8 parser returns a string for count(*), while JobPage + // promises a numeric total to API, CLI and federation consumers. + const counted = await pool.query<{ total: string }>( `select count(*)::bigint as total from jobs j join organisations o on o.id = j.org_id ${whereSql}`, params, ); @@ -315,12 +324,24 @@ export async function searchJobs( return { items: rows.rows.map(toJob), - total: counted.rows[0]?.total ?? 0, + total: Number(counted.rows[0]?.total ?? 0), limit: query.limit, offset: query.offset, }; } +/** Every listing the signed-in user can manage, including drafts and closed jobs. */ +export async function listJobsForUser(pool: pg.Pool, userId: string): Promise { + const rows = await pool.query( + `${SELECT} + join memberships m on m.org_id = j.org_id + where m.user_id = $1 + order by j.updated_at desc, j.created_at desc`, + [userId], + ); + return rows.rows.map(toJob); +} + /** * Does one published listing satisfy a query? * @@ -378,11 +399,20 @@ export async function getJobBySlug( slug: string, options: { includeUnpublished?: boolean } = {}, ): Promise { + // The slug travels into a SQL parameter, and a NUL byte is not text: + // PostgreSQL refuses it outright, so `"job": "\u0000"` on POST /inbox + // answered 500 where an unknown slug answers "no such job". Drop the byte + // the way parseQuery drops it from a search, and treat a slug that cleans + // away to nothing as absent rather than as a lookup for the empty slug. + const cleaned = slug.replace(/\0/g, '').trim(); + if (cleaned === '') return null; const visible = options.includeUnpublished === true ? '' : `and j.status = 'published' and j.published_at is not null and j.published_at <= now() and (j.expires_at is null or j.expires_at > now())`; - const result = await pool.query(`${SELECT} where j.slug = $1 ${visible} limit 1`, [slug]); + const result = await pool.query(`${SELECT} where j.slug = $1 ${visible} limit 1`, [ + cleaned, + ]); const row = result.rows[0]; return row === undefined ? null : toJob(row); } @@ -488,6 +518,21 @@ function normaliseApply(input: Record): ApplyMethod | string { const FIELD_TYPES = ['text', 'textarea', 'email', 'url', 'select', 'file'] as const; +function applyOptions(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const options: string[] = []; + for (const entry of value) { + const option = clean(entry, 80); + const key = option.toLowerCase(); + if (key === '' || seen.has(key)) continue; + seen.add(key); + options.push(option); + if (options.length >= 30) break; + } + return options; +} + export function normaliseApplySchema(input: unknown): ApplySchema | null { if (typeof input !== 'object' || input === null) return null; const fields = (input as Record)['fields']; @@ -511,7 +556,7 @@ export function normaliseApplySchema(input: unknown): ApplySchema | null { ? (type as (typeof FIELD_TYPES)[number]) : 'text', required: field['required'] === true, - ...(Array.isArray(field['options']) ? { options: parseList(field['options'], 30, 80) } : {}), + ...(Array.isArray(field['options']) ? { options: applyOptions(field['options']) } : {}), ...(clean(field['help'], 200) ? { help: clean(field['help'], 200) } : {}), maxLength: Math.min(20_000, Math.max(1, Number(field['maxLength']) || 2000)), }); @@ -830,6 +875,61 @@ export async function setPay(pool: pg.Pool, id: string, pay: Pay): Promise, +): Promise { + const values: unknown[] = [id]; + const assignments: string[] = []; + const set = (column: string, value: unknown): void => { + values.push(value); + assignments.push(`${column} = $${values.length}`); + }; + if (fields.has('title')) set('title', input.title); + if (fields.has('description')) set('description', input.description); + if (fields.has('employmentType')) set('employment_type', input.employmentType); + if (fields.has('workplace')) set('workplace', input.workplace); + if (fields.has('seniority')) set('seniority', input.seniority); + if (fields.has('location')) set('location', input.location); + if (fields.has('remoteRegions')) set('remote_regions', input.remoteRegions); + if (fields.has('tags')) set('tags', input.tags); + if (fields.has('stack')) set('stack', input.stack); + if (fields.has('requirements')) set('requirements', input.requirements); + if (fields.has('responsibilities')) set('responsibilities', input.responsibilities); + if (fields.has('agentPolicy')) set('agent_policy', input.agentPolicy); + if (fields.has('expiresAt')) set('expires_at', input.expiresAt); + if (fields.has('pay')) { + const salary = salaryFromPay(input.pay); + set('salary_min', salary.min); + set('salary_max', salary.max); + set('salary_currency', salary.currency); + set('salary_period', salary.period); + set('salary_equity', input.pay.equity); + set('salary_unpaid', input.pay.unpaid); + set('pay_lines', JSON.stringify(input.pay.lines)); + set('pay_method', input.pay.method); + } + if (assignments.length > 0) { + assignments.push('updated_at = now()'); + await pool.query(`update jobs set ${assignments.join(', ')} where id = $1`, values); + } + return getJobById(pool, id); +} + /** The listing imported from this URL, if there is one. */ export async function getJobBySourceUrl(pool: pg.Pool, url: string): Promise { const result = await pool.query<{ id: string }>( diff --git a/src/core/orgs.ts b/src/core/orgs.ts index 3a91d77..390cb02 100644 --- a/src/core/orgs.ts +++ b/src/core/orgs.ts @@ -35,7 +35,15 @@ function toOrg(row: OrgRow): Organisation { const SELECT = `select id, slug, name, website, logo_url, description, created_at from organisations`; export async function getOrgBySlug(pool: pg.Pool, slug: string): Promise { - const result = await pool.query(`${SELECT} where slug = $1`, [slug]); + // The slug travels into a SQL parameter, and a NUL byte is not text: + // PostgreSQL refuses it outright, so `"org": "\u0000"` on POST /jobs or + // /updates - and `"as": "\u0000"` on POST /inbox - answered 500 where an + // unknown slug answers "no such employer". Drop the byte the way parseQuery + // drops it from a search, and treat a slug that cleans away to nothing as + // absent rather than as a lookup for the empty slug. + const cleaned = slug.replace(/\0/g, '').trim(); + if (cleaned === '') return null; + const result = await pool.query(`${SELECT} where slug = $1`, [cleaned]); const row = result.rows[0]; return row === undefined ? null : toOrg(row); } diff --git a/src/core/profile.ts b/src/core/profile.ts new file mode 100644 index 0000000..3d87c45 --- /dev/null +++ b/src/core/profile.ts @@ -0,0 +1,69 @@ +/** + * The shared Profullstack profile (@profullstack/profile): email, first and + * last name, company domain, job title and LinkedIn URL, in `user_profiles`. + * + * Asked for on /signup only, carried on the magic link (the account is created + * when the link is spent), and editable afterwards on /me and through + * GET/PUT/PATCH /api/v1/me/profile. Never shown to anyone but its owner. + */ + +import type pg from 'pg'; +import { + createProfileHandlers, + createProfileStore, + saveSignupProfile, + type ProfileStore, +} from '@profullstack/profile'; + +const stores = new WeakMap(); + +export function profileStore(pool: pg.Pool): ProfileStore { + let store = stores.get(pool); + if (store === undefined) { + store = createProfileStore(pool); + stores.set(pool, store); + } + return store; +} + +type Handlers = ReturnType; +const handlers = new WeakMap(); + +/** + * GET/PUT/PATCH /api/v1/me/profile: the package's own handlers. `getUser` + * receives the Hono context as its second argument and reads the viewer the + * session middleware already resolved. + */ +export function profileHandlers(pool: pg.Pool): Handlers { + let found = handlers.get(pool); + if (found === undefined) { + found = createProfileHandlers({ + store: profileStore(pool), + getUser: (_request, c) => { + const viewer = (c as { get(key: 'viewer'): { id: string; email: string } | null }).get( + 'viewer', + ); + return viewer === null ? null : { id: viewer.id, email: viewer.email }; + }, + }); + handlers.set(pool, found); + } + return found; +} + +/** + * Save the fields a signup carried on its link. A failure is logged and + * swallowed: it must never cost anybody their sign-in, and /me asks again. + */ +export async function saveCarriedProfile( + pool: pg.Pool, + user: { id: string; email: string }, + profile: Record | null, +): Promise { + if (profile === null) return; + try { + await saveSignupProfile(profileStore(pool), user.id, profile, { email: user.email }); + } catch (error) { + console.error('[auth] signup profile save failed:', (error as Error).message); + } +} diff --git a/src/core/push.ts b/src/core/push.ts index 9f8eff3..24419f7 100644 --- a/src/core/push.ts +++ b/src/core/push.ts @@ -46,6 +46,39 @@ export interface PushPayload { url: string; } +// RFC 8291 notes that push services are not required to accept bodies over +// 4096 bytes. The aes128gcm header is 86 bytes, and a one-record payload adds +// a delimiter and a 16-byte authentication tag, leaving 3993 plaintext bytes. +const MAX_PUSH_PLAINTEXT_BYTES = 3993; + +function pushPlaintext(payload: PushPayload): Buffer { + const fitted = { ...payload }; + const serialized = (): Buffer => Buffer.from(JSON.stringify(fitted)); + if (serialized().length <= MAX_PUSH_PLAINTEXT_BYTES) return serialized(); + + // Search text can be arbitrarily long. Keep the useful title and target + // intact whenever possible, shortening the descriptive body first. + for (const field of ['body', 'title', 'url'] as const) { + const characters = Array.from(fitted[field]); + let low = 0; + let high = characters.length; + let best = ''; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + fitted[field] = characters.slice(0, middle).join(''); + if (serialized().length <= MAX_PUSH_PLAINTEXT_BYTES) { + best = fitted[field]; + low = middle + 1; + } else { + high = middle - 1; + } + } + fitted[field] = best; + if (serialized().length <= MAX_PUSH_PLAINTEXT_BYTES) return serialized(); + } + return serialized(); +} + export interface PushResult { endpoint: string; status: number | null; @@ -190,7 +223,7 @@ export function buildRequest( options: { ttl?: number } = {}, ): { url: string; init: RequestInit } { const audience = new URL(subscription.endpoint).origin; - const body = encrypt(subscription, Buffer.from(JSON.stringify(payload))); + const body = encrypt(subscription, pushPlaintext(payload)); return { url: subscription.endpoint, init: { diff --git a/src/core/rankings.ts b/src/core/rankings.ts index 79f73e7..bde9a2d 100644 --- a/src/core/rankings.ts +++ b/src/core/rankings.ts @@ -61,7 +61,7 @@ export function createRankings( pool.query<{ slug: string; name: string; day: string; views: number }>( `select j.slug, j.title || ' at ' || o.name as name, v.day::text as day, v.views from job_views v join jobs j on j.id = v.job_id join organisations o on o.id = j.org_id - where ${live} and v.views > 0 and v.day >= current_date - 400`, + where ${live} and v.views > 0`, ), pool.query<{ slug: string; name: string; at: string }>( `select j.slug, j.title || ' at ' || o.name as name, coalesce(a.submitted_at, a.created_at) as at diff --git a/src/core/resumes.ts b/src/core/resumes.ts index 34f4e23..4b24073 100644 --- a/src/core/resumes.ts +++ b/src/core/resumes.ts @@ -83,16 +83,27 @@ export async function getResume( userId: string, slug: string, ): Promise { + // The slug comes from the application body - `"resumeSlug": "\u0000"` on + // POST /jobs/:slug/apply answered 500 - and a NUL byte is not text: + // PostgreSQL refuses it outright. Drop the byte; nothing matches it anyway. + const cleaned = slug.replace(/\0/g, '').trim(); + if (cleaned === '') return null; const result = await pool.query(`${SELECT} where user_id = $1 and slug = $2`, [ userId, - slug, + cleaned, ]); const row = result.rows[0]; return row === undefined ? null : toResume(row); } export async function getResumeById(pool: pg.Pool, id: string): Promise { - const result = await pool.query(`${SELECT} where id = $1`, [id]); + // The id comes from the application body - `"resumeId": "\u0000"` on + // POST /jobs/:slug/apply answered 500 - and a NUL byte is not text: + // PostgreSQL refuses it outright. Drop the byte; an id that cleans away to + // nothing matches nothing. + const cleaned = id.replace(/\0/g, '').trim(); + if (cleaned === '') return null; + const result = await pool.query(`${SELECT} where id = $1`, [cleaned]); const row = result.rows[0]; return row === undefined ? null : toResume(row); } @@ -145,14 +156,32 @@ export async function ensurePublicSlug(pool: pg.Pool, resume: Resume): Promise 0) return candidate; + // Another request may have shared this same resume while this one waited + // on its row lock. Return the address that request minted instead of + // trying suffixes against a resume that is no longer unclaimed. + const current = await pool.query<{ public_slug: string | null }>( + `select public_slug from resumes where id = $1`, + [resume.id], + ); + const currentSlug = current.rows[0]?.public_slug; + if (currentSlug != null) return currentSlug; } return null; } @@ -184,6 +213,12 @@ export async function publicResumeSource( pool: pg.Pool, publicSlug: string, ): Promise<{ name: string; mime: string; bytes: Buffer } | null> { + // The slug travels into a SQL parameter, and a NUL byte is not text: + // PostgreSQL refuses it outright. Drop the byte the way parseQuery drops it + // from a search, and treat a slug that cleans away to nothing as absent + // rather than as a lookup for the empty slug. + const slug = publicSlug.replace(/\0/g, '').trim(); + if (slug === '') return null; const result = await pool.query<{ source_name: string | null; source_mime: string | null; @@ -191,7 +226,7 @@ export async function publicResumeSource( }>( `select source_name, source_mime, source_bytes from resumes where public_slug = $1 and visibility in ('link', 'public')`, - [publicSlug], + [slug], ); const row = result.rows[0]; if (row === undefined || row.source_bytes === null) return null; @@ -203,18 +238,27 @@ export async function publicResumeSource( } export async function getPublicResume(pool: pg.Pool, publicSlug: string): Promise { + // Same slug, same NUL byte: `GET /inbox/new?candidate=%00` answered 500 + // through this lookup, where a slug that matches nothing is the 404 the + // page already knows how to give. + const slug = publicSlug.replace(/\0/g, '').trim(); + if (slug === '') return null; const result = await pool.query( `${SELECT} where public_slug = $1 and visibility in ('link', 'public')`, - [publicSlug], + [slug], ); const row = result.rows[0]; return row === undefined ? null : toResume(row); } export async function getSharedResume(pool: pg.Pool, id: string): Promise { + // Same body, same NUL byte as getResumeById above: `"resumeId": "\u0000"` + // reached this lookup first and answered 500. + const cleaned = id.replace(/\0/g, '').trim(); + if (cleaned === '') return null; const result = await pool.query( `${SELECT} where id = $1 and visibility in ('link', 'public')`, - [id], + [cleaned], ); const row = result.rows[0]; return row === undefined ? null : toResume(row); @@ -290,7 +334,10 @@ export async function updateResume( set markdown = $3, parsed = $4::jsonb, title = coalesce(nullif($5, ''), title), - visibility = coalesce($6, visibility) + visibility = coalesce($6, visibility), + source_name = case when $3 is distinct from markdown then null else source_name end, + source_mime = case when $3 is distinct from markdown then null else source_mime end, + source_bytes = case when $3 is distinct from markdown then null else source_bytes end where user_id = $1 and slug = $2 returning id, user_id, slug, title, markdown, parsed, visibility, public_slug, source_name, created_at, updated_at`, diff --git a/src/core/tracker.ts b/src/core/tracker.ts index 7db9391..62ba22a 100644 --- a/src/core/tracker.ts +++ b/src/core/tracker.ts @@ -104,10 +104,16 @@ export function parseEvents(value: unknown, currency: string): TrackerEvent[] { 'Event currency differs from the fleet. Import into a fleet with the matching currency.', ); const at = e['at'] ?? null; + const datePart = typeof at === 'string' ? /^\d{4}-\d{2}-\d{2}/.exec(at)?.[0] : undefined; + const validCalendarDate = + datePart !== undefined && + Number.isFinite(Date.parse(`${datePart}T00:00:00.000Z`)) && + new Date(`${datePart}T00:00:00.000Z`).toISOString().slice(0, 10) === datePart; if ( at !== null && (typeof at !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(at) || + !validCalendarDate || !Number.isFinite(Date.parse(at))) ) throw new TrackerProblem('Event time must be an ISO timestamp or null.'); diff --git a/src/core/updates.ts b/src/core/updates.ts index 400ca24..3151438 100644 --- a/src/core/updates.ts +++ b/src/core/updates.ts @@ -187,6 +187,79 @@ export function normaliseLink(value: unknown): string | null { export interface UpdateInput { body: unknown; link?: unknown; + /** + * The board's own public URL. When the link points back at this board, the + * page it names has to exist: a link here is the one address an update + * could have checked and did not. + */ + board?: string; +} + +/** The board's pages an update can link to, and what serves each one. */ +const BOARD_PAGES: Record = { + candidates: `select 1 from resumes where public_slug = $1 and visibility in ('link', 'public')`, + jobs: `select 1 from jobs where slug = $1 and status = 'published' and published_at is not null + and published_at <= now() and (expires_at is null or expires_at > now())`, + employers: `select 1 from organisations where slug = $1`, +}; + +function sameBoard(a: URL, b: URL): boolean { + const bare = (host: string): string => host.replace(/^www\./, ''); + return bare(a.hostname) === bare(b.hostname); +} + +/** + * Why a link back into this board goes nowhere, or null when it is fine. + * + * Found in production: an agent posted its update with a link to the + * candidate page it expected to have, `/candidates/-`, not + * the one the board minted, and the 404 went out in /updates and every feed + * for as long as the update stood. Anything outside the board is the + * author's business; a page here is ours to check. + */ +export async function boardLinkProblem( + pool: pg.Pool, + link: string, + board: string, + authorId: string, + target: Target, +): Promise { + let url: URL; + let home: URL; + try { + url = new URL(link); + home = new URL(board); + } catch { + return null; + } + if (!sameBoard(url, home)) return null; + const [section, slug] = url.pathname.split('/').filter((part) => part !== ''); + const sql = section === undefined ? undefined : BOARD_PAGES[section]; + if (sql === undefined || slug === undefined) return null; + + // Two inputs that are not a page here must not answer 500 instead of the + // sentence below: a malformed escape, which decodeURIComponent throws on, + // and a NUL byte, which PostgreSQL refuses outright. Both clean away to + // nothing, and nothing is not a page on this board. + let decoded: string; + try { + decoded = decodeURIComponent(slug); + } catch { + decoded = ''; + } + decoded = decoded.replace(/\0/g, '').trim(); + + if (decoded !== '') { + const found = await pool.query(sql, [decoded]); + if (found.rows.length > 0) return null; + } + + let hint = ''; + if (section === 'candidates' && target.kind === 'candidate') { + const own = await candidateSlugFor(pool, authorId); + if (own !== null) hint = ` Your own page is ${home.origin}/candidates/${own}.`; + } + return `That link goes to /${section}/${slug}, which is not a page on this board.${hint}`; } /** @@ -211,6 +284,10 @@ export async function postUpdate( if (link === null && clean(input.link, 500) !== '') { return 'That link is not a public http(s) URL, so nobody else could open it.'; } + if (link !== null && input.board !== undefined) { + const problem = await boardLinkProblem(pool, link, input.board, authorId, target); + if (problem !== null) return problem; + } // The author has to be allowed to post as this target. An org post needs // membership; a candidate post can only ever be your own. @@ -227,34 +304,52 @@ export async function postUpdate( const column = target.kind === 'employer' ? 'org_id' : 'user_id'; const owner = target.kind === 'employer' ? target.orgId : target.userId; - // The rate limit and the duplicate check are one round trip, and both are - // asked of the AUTHOR rather than of the target: posting the same thing to - // four employers you belong to is the spam this is here to stop. - const recent = await pool.query<{ today: string; same: string }>( - `select count(*) filter (where created_at > now() - interval '24 hours') as today, - count(*) filter (where body = $2 and created_at > now() - interval '30 days') as same - from updates where author_id = $1`, - [authorId, body], - ); - const today = Number(recent.rows[0]?.today ?? 0); - const same = Number(recent.rows[0]?.same ?? 0); - if (today >= DAILY_LIMIT) { - return `That is ${DAILY_LIMIT} updates today, which is the limit. Tomorrow.`; + // The limits and insert share an author-scoped transaction lock. Without + // it, two tabs can both pass the checks before either inserts, duplicating + // a post or exceeding the daily limit. + const client = await pool.connect(); + try { + await client.query('begin'); + await client.query('select pg_advisory_xact_lock(hashtextextended($1, 0))', [authorId]); + + // Count the AUTHOR rather than the target: posting the same thing to four + // employers they belong to is the spam this is here to stop. + const recent = await client.query<{ today: string; same: string }>( + `select count(*) filter (where created_at > statement_timestamp() - interval '24 hours') as today, + count(*) filter (where body = $2 and created_at > statement_timestamp() - interval '30 days') as same + from updates where author_id = $1`, + [authorId, body], + ); + const today = Number(recent.rows[0]?.today ?? 0); + const same = Number(recent.rows[0]?.same ?? 0); + if (today >= DAILY_LIMIT) { + await client.query('rollback'); + return `That is ${DAILY_LIMIT} updates today, which is the limit. Tomorrow.`; + } + if (same > 0) { + await client.query('rollback'); + return 'You already posted that. Say something else, or link it again later.'; + } + + const created = await client.query( + `with inserted as ( + insert into updates (${column}, author_id, body, link) + values ($1, $2, $3, $4) + returning id, body, link, created_at, org_id, user_id + ) + ${selectFrom('inserted')}`, + [owner, authorId, body, link], + ); + const row = created.rows[0]; + if (row === undefined) throw new Error('update insert returned no row'); + await client.query('commit'); + return toUpdate(row); + } catch (error) { + await client.query('rollback'); + throw error; + } finally { + client.release(); } - if (same > 0) return 'You already posted that. Say something else, or link it again later.'; - - const created = await pool.query( - `with inserted as ( - insert into updates (${column}, author_id, body, link) - values ($1, $2, $3, $4) - returning id, body, link, created_at, org_id, user_id - ) - ${selectFrom('inserted')}`, - [owner, authorId, body, link], - ); - const row = created.rows[0]; - if (row === undefined) throw new Error('update insert returned no row'); - return toUpdate(row); } function cap(limit: number): number { @@ -440,8 +535,14 @@ export type Scope = | { kind: 'unknown'; slug: string }; export async function scopeFrom(pool: pg.Pool, params: URLSearchParams): Promise { - const org = (params.get('org') ?? '').trim(); - const candidate = (params.get('candidate') ?? '').trim(); + // Both filters travel into SQL parameters, and a NUL byte is not text: + // PostgreSQL refuses it outright, so `?org=%00` and `?candidate=%00` + // answered 500 on every surface that resolves a scope - the API, the RSS + // feed and the Markdown feed at once. Drop the byte the way parseQuery + // drops it from a search, and treat a filter that cleans away to nothing + // as absent rather than as a lookup for the empty slug. + const org = (params.get('org') ?? '').replace(/\0/g, '').trim(); + const candidate = (params.get('candidate') ?? '').replace(/\0/g, '').trim(); if (org !== '') { const row = await pool.query<{ id: string; name: string }>( @@ -509,12 +610,17 @@ export async function candidateSlugFor(pool: pg.Pool, userId: string): Promise { + // The slug travels into a SQL parameter, and a NUL byte is not text: + // PostgreSQL refuses it outright. The scope parse cleans before calling + // this, but the lookup is the boundary that has to hold for every caller. + const cleaned = slug.replace(/\0/g, '').trim(); + if (cleaned === '') return null; // Matches what /candidates/:slug actually serves, so a page a person can // open is a page they can follow. const result = await pool.query<{ user_id: string }>( `select user_id from resumes where public_slug = $1 and visibility in ('link', 'public') limit 1`, - [slug], + [cleaned], ); return result.rows[0]?.user_id ?? null; } diff --git a/src/core/watches.ts b/src/core/watches.ts index 7ae31b1..b13e376 100644 --- a/src/core/watches.ts +++ b/src/core/watches.ts @@ -158,29 +158,57 @@ export async function createWatch( return 'Narrow the search first: a watch on every listing is the /feed, and it already exists.'; } const stored = toStored(query); - const existing = await pool.query( - `select id, query, label, email, created_at, last_notified_at - from watches where user_id = $1 and query = $2::jsonb`, - [userId, JSON.stringify(stored)], - ); - const found = existing.rows[0]; - if (found !== undefined) return { watch: toWatch(found, slugPath), created: false }; + const client = await pool.connect(); + try { + await client.query('begin'); + // Serialize distinct searches for the same account before checking the cap. + // The unique search index only protects duplicate searches, not the limit. + await client.query('select id from users where id = $1 for update', [userId]); + const findExisting = async (): Promise => { + const existing = await client.query( + `select id, query, label, email, created_at, last_notified_at + from watches where user_id = $1 and query = $2::jsonb`, + [userId, JSON.stringify(stored)], + ); + const row = existing.rows[0]; + return row === undefined ? null : toWatch(row, slugPath); + }; + const found = await findExisting(); + if (found !== null) { + await client.query('commit'); + return { watch: found, created: false }; + } - const count = await pool.query<{ n: number }>( - `select count(*)::int as n from watches where user_id = $1`, - [userId], - ); - if ((count.rows[0]?.n ?? 0) >= WATCHES_PER_ACCOUNT) { - return `You are watching ${WATCHES_PER_ACCOUNT} searches already. Remove one to add another.`; + const count = await client.query<{ n: number }>( + `select count(*)::int as n from watches where user_id = $1`, + [userId], + ); + if ((count.rows[0]?.n ?? 0) >= WATCHES_PER_ACCOUNT) { + await client.query('commit'); + return `You are watching ${WATCHES_PER_ACCOUNT} searches already. Remove one to add another.`; + } + const inserted = await client.query( + `insert into watches (user_id, query, label, email) values ($1, $2::jsonb, $3, $4) + on conflict (user_id, query) do nothing + returning id, query, label, email, created_at, last_notified_at`, + [userId, JSON.stringify(stored), labelFor(watchQuery(query)), options.email !== false], + ); + const row = inserted.rows[0]; + if (row === undefined) { + const createdByPeer = await findExisting(); + if (createdByPeer === null) throw new Error('watch insert returned no row'); + await client.query('commit'); + return { watch: createdByPeer, created: false }; + } + const watch = toWatch(row, slugPath); + await client.query('commit'); + return { watch, created: true }; + } catch (error) { + await client.query('rollback'); + throw error; + } finally { + client.release(); } - const inserted = await pool.query( - `insert into watches (user_id, query, label, email) values ($1, $2::jsonb, $3, $4) - returning id, query, label, email, created_at, last_notified_at`, - [userId, JSON.stringify(stored), labelFor(watchQuery(query)), options.email !== false], - ); - const row = inserted.rows[0]; - if (row === undefined) throw new Error('watch insert returned no row'); - return { watch: toWatch(row, slugPath), created: true }; } export async function deleteWatch(pool: pg.Pool, userId: string, id: string): Promise { @@ -210,7 +238,7 @@ export async function listNotifications( `select id, kind, title, body, url, created_at, read_at from notifications where user_id = $1 ${options.unreadOnly === true ? 'and read_at is null' : ''} order by created_at desc limit $2`, - [userId, Math.min(NOTIFICATIONS_KEPT, Math.max(1, options.limit ?? 50))], + [userId, Math.min(NOTIFICATIONS_KEPT, Math.max(1, options.limit ?? NOTIFICATIONS_KEPT))], ); return rows.rows.map((row) => ({ id: row.id, diff --git a/src/core/zip.ts b/src/core/zip.ts index b6a243c..51b5711 100644 --- a/src/core/zip.ts +++ b/src/core/zip.ts @@ -36,7 +36,24 @@ function findEndOfCentralDirectory(buffer: Buffer): number { // The comment field means the record is not necessarily at the very end. const earliest = Math.max(0, buffer.length - 0xffff - 22); for (let offset = buffer.length - 22; offset >= earliest; offset -= 1) { - if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset; + if (buffer.readUInt32LE(offset) !== EOCD_SIGNATURE) continue; + if (offset + 22 + buffer.readUInt16LE(offset + 20) !== buffer.length) continue; + + // A matching signature inside the comment can itself look like a complete + // empty EOCD. Check that its directory fields point to the real directory. + const diskEntries = buffer.readUInt16LE(offset + 8); + const entries = buffer.readUInt16LE(offset + 10); + const directorySize = buffer.readUInt32LE(offset + 12); + const directoryOffset = buffer.readUInt32LE(offset + 16); + if (diskEntries !== entries || directoryOffset + directorySize > offset) continue; + if (entries === 0) { + if (directorySize === 0 && directoryOffset === offset) return offset; + } else if ( + directoryOffset + 4 <= offset && + buffer.readUInt32LE(directoryOffset) === CENTRAL_SIGNATURE + ) { + return offset; + } } throw new ZipProblem('This does not look like a .docx (no ZIP directory found).'); } @@ -72,6 +89,11 @@ function readEntries(buffer: Buffer): Entry[] { return entries; } +/** Whether a structurally valid archive contains a named entry. */ +export function hasZipEntry(buffer: Buffer, wanted: string): boolean { + return readEntries(buffer).some((entry) => entry.name === wanted); +} + export function readZipEntry(buffer: Buffer, wanted: string): Buffer | null { const entry = readEntries(buffer).find((item) => item.name === wanted); if (entry === undefined) return null; diff --git a/src/db/pool.ts b/src/db/pool.ts index c147ddd..65d6be7 100644 --- a/src/db/pool.ts +++ b/src/db/pool.ts @@ -17,8 +17,16 @@ export type Pool = pg.Pool; // default, which then serialise differently depending on which surface does // the serialising. Everything in this codebase treats a time as an ISO string, // so the conversion happens once, here. -types.setTypeParser(1184, (value: string) => new Date(value).toISOString()); -types.setTypeParser(1114, (value: string) => new Date(`${value}Z`).toISOString()); +// +// A timestamp older than the server timezone's adoption of standard time +// renders with the local-mean-time offset, which carries seconds +// (`1900-01-15 08:27:52+08:27:52` in Asia/Seoul). Date rejects an offset with +// seconds, the parse used to land in toISOString() as an Invalid Date, and +// every route reading such a row answered 500. Truncating the offset to whole +// minutes shifts the instant by under a second, which only ever touches +// dates far older than any job on the board. +types.setTypeParser(1184, (value: string) => parseTimestamp(value)); +types.setTypeParser(1114, (value: string) => parseTimestamp(`${value}Z`)); // `int8` comes back as a string so that large values survive. Every count in // this schema is comfortably inside Number.MAX_SAFE_INTEGER. types.setTypeParser(20, (value: string) => Number.parseInt(value, 10)); @@ -28,7 +36,12 @@ let pool: pg.Pool | null = null; export function getPool(databaseUrl: string): pg.Pool { if (pool !== null) return pool; pool = new Pool({ - connectionString: databaseUrl, + // Without this the URL wins: pg copies whatever it parses out of the + // connection string over the options given here, and pg-connection-string + // turns `sslmode=require` into full certificate verification. That made + // the PGSSLMODE opt-in below a no-op the moment a URL carried sslmode, and + // a database with a self-signed certificate unreachable. + connectionString: withoutSslMode(databaseUrl), max: Number.parseInt(process.env['PG_POOL_MAX'] ?? '10', 10) || 10, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 10_000, @@ -48,6 +61,22 @@ export function getPool(databaseUrl: string): pg.Pool { return pool; } +/** Parse a Postgres timestamp into an ISO string, tolerating LMT offsets. */ +function parseTimestamp(value: string): string { + const direct = new Date(value); + const parsed = Number.isNaN(direct.getTime()) + ? new Date(value.replace(/([+-]\d{2}:\d{2}):\d{2}$/, '$1')) + : direct; + return parsed.toISOString(); +} + +/** The URL minus its sslmode parameter; SSL is decided by needsSsl() instead. */ +function withoutSslMode(databaseUrl: string): string { + return databaseUrl + .replace(/([?&])sslmode=[^&]*(&|$)/, (_match, lead: string, trail: string) => (trail ? lead : '')) + .replace(/[?&]$/, ''); +} + function needsSsl(databaseUrl: string): boolean { const mode = process.env['PGSSLMODE']?.trim().toLowerCase(); if (mode === 'disable') return false; diff --git a/src/directory/registry.ts b/src/directory/registry.ts index a9ecc5e..2f83847 100644 --- a/src/directory/registry.ts +++ b/src/directory/registry.ts @@ -110,14 +110,25 @@ export async function listInstances( where.push(`checked_at > now() - ($${params.length} || ' milliseconds')::interval`); where.push('failures = 0'); } - if (options.topic) { - params.push(options.topic.toLowerCase()); + // Both filters are strings that travel into SQL parameters, and a NUL byte + // is not text: PostgreSQL refuses it outright, so `?topic=%00` or `?q=%00` + // on this route answered 500. Drop the byte - only the byte - and treat a + // filter that is nothing but NUL as absent rather than as a search for the + // empty string. + const topic = (options.topic ?? '').replace(/\0/g, '').trim().toLowerCase(); + if (topic !== '') { + params.push(topic); where.push(`descriptor -> 'topics' ? $${params.length}`); } - if (options.q) { - params.push(`%${options.q.toLowerCase()}%`); + const q = (options.q ?? '').replace(/\0/g, '').trim(); + if (q !== '') { + // This is a substring search over directory text, not a user-supplied + // SQL LIKE pattern. Escape its metacharacters so `%` and `_` in names or + // URLs are searched as characters. + const query = q.toLowerCase().replace(/[\\%_]/g, '\\$&'); + params.push(`%${query}%`); where.push( - `(lower(descriptor ->> 'name') like $${params.length} or lower(descriptor ->> 'tagline') like $${params.length} or url like $${params.length})`, + `(lower(descriptor ->> 'name') like $${params.length} escape E'\\\\' or lower(descriptor ->> 'tagline') like $${params.length} escape E'\\\\' or url like $${params.length} escape E'\\\\')`, ); } @@ -153,7 +164,7 @@ export async function getInstance(pool: pg.Pool, url: string): Promise { const result = await pool.query<{ topic: string; instances: number }>( - `select topic, count(*)::bigint as instances + `select topic, count(distinct instances.id)::bigint as instances from instances, jsonb_array_elements_text(descriptor -> 'topics') as topic where blocked = false group by topic diff --git a/src/markup/markdown.ts b/src/markup/markdown.ts index f73f852..c27b0c5 100644 --- a/src/markup/markdown.ts +++ b/src/markup/markdown.ts @@ -224,9 +224,11 @@ function tryList(lines: string[], start: number, options: MarkdownOptions): Bloc const firstMarker = first[2] ?? ''; const ordered = /\d/.test(firstMarker); + const markerStyle = ordered ? firstMarker.slice(-1) : firstMarker; const orderedStart = ordered ? Number.parseInt(firstMarker, 10) : 1; const indent = (first[1] ?? '').length; const items: string[][] = []; + let loose = false; let contentIndent = indent + 2; let index = start; @@ -235,9 +237,13 @@ function tryList(lines: string[], start: number, options: MarkdownOptions): Bloc const match = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/.exec(line); if (match !== null && (match[1] ?? '').length <= indent) { - // A different marker type at the same level starts a new list rather - // than silently continuing this one. - if (/\d/.test(match[2] ?? '') !== ordered) break; + // A different marker style at the same level starts a new list rather + // than silently continuing this one. CommonMark treats 1. and 1) + // as different ordered lists, just as - and * are different bullet lists. + const currentMarker = match[2] ?? ''; + const currentOrdered = /\d/.test(currentMarker); + const currentStyle = currentOrdered ? currentMarker.slice(-1) : currentMarker; + if (currentOrdered !== ordered || currentStyle !== markerStyle) break; const content = match[3] ?? ''; // Each item may have a different marker width (for example 9. then 10.) // or padding. Remove exactly that prefix from its continuation lines. @@ -250,11 +256,27 @@ function tryList(lines: string[], start: number, options: MarkdownOptions): Bloc const current = items[items.length - 1]; if (current === undefined) break; if (line.trim() === '') { - // A blank run continues the item only when followed by indented content. - // Keep it in the item so fenced code does not end at its first blank line. + // A matching sibling after blank lines continues the same loose list. + // Otherwise preserve blanks only before indented content, so fences keep + // their blank lines and outside paragraphs still end the list. let next = index + 1; while (next < lines.length && (lines[next] ?? '').trim() === '') next += 1; const following = lines[next] ?? ''; + const sibling = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/.exec(following); + if ( + sibling !== null && + (sibling[1] ?? '').length === indent && + !/^\s{0,3}([-*_])\s*(\1\s*){2,}$/.test(following) + ) { + const siblingMarker = sibling[2] ?? ''; + const siblingOrdered = /\d/.test(siblingMarker); + const siblingStyle = siblingOrdered ? siblingMarker.slice(-1) : siblingMarker; + if (siblingOrdered === ordered && siblingStyle === markerStyle) { + loose = true; + index = next; + continue; + } + } if ( next === lines.length || following.length - following.trimStart().length < contentIndent @@ -282,12 +304,14 @@ function tryList(lines: string[], start: number, options: MarkdownOptions): Bloc const rendered = items .map((item) => { const body = item.join('\n'); - // A one-line item stays inline, so a bullet list does not gain a - // paragraph's worth of vertical space per bullet. - let inner = item.length === 1 ? renderInline(body, options) : renderMarkdown(body, options); + // Tight single-line items stay inline; blank-separated siblings retain + // paragraph wrappers across the whole loose list. + let inner = + item.length === 1 && !loose ? renderInline(body, options) : renderMarkdown(body, options); // Unwrap only a single paragraph. A multi-block item can start and end // with different paragraphs; removing those outer tags leaves both incomplete. if ( + !loose && item.length > 1 && inner.startsWith('

') && inner.indexOf('

') === inner.length - 4 @@ -309,45 +333,67 @@ const MARK = String.fromCharCode(0); /** * Inline markup. * - * Code spans are pulled out first and put back last, so a backticked - * `**not bold**` stays literal and an asterisk inside code cannot open - * emphasis that then runs off through the rest of the paragraph. + * Code spans and generated HTML are held aside while prose is formatted, + * so Markdown markers stay literal in code and URL/attribute values. */ export function renderInline(source: string, options: MarkdownOptions = {}): string { - const codes: string[] = []; + const fragments: string[] = []; const rel = options.linkRel ?? DEFAULT_REL; + const restore = (text: string): string => + text.replace(new RegExp(`${MARK}(\\d+)${MARK}`, 'g'), (_whole, id: string) => { + return fragments[Number(id)] ?? ''; + }); + const protectHtml = (html: string): string => { + // Link labels may already contain protected code spans. Resolve those + // before storing the whole tag so restoration only needs one pass. + fragments.push(restore(html)); + return `${MARK}${fragments.length - 1}${MARK}`; + }; - let text = protectCodeSpans(source, codes); + let text = protectCodeSpans(source, fragments); text = escapeHtml(text); - text = replaceInlineLinks(text, options, rel); + text = replaceInlineLinks(text, options, rel, protectHtml, restore); // Bare URLs. Trailing punctuation is left outside the link, because a URL at // the end of a sentence is the common case and the full stop is not part of - // it. - text = text.replace(/(^|[\s(])(https?:\/\/[^\s<>"']+)/g, (_whole, lead: string, href: string) => { - // The match ran on escaped text, so decode before trimming: an entity's - // own `;` would otherwise count as trailing punctuation and corrupt it. - const decoded = unescapeUrl(href); - const trimmed = trimBareUrl(decoded); - const tail = decoded.slice(trimmed.length); - const url = safeUrl(trimmed); - if (url === null) return `${lead}${href}`; - return `${lead}
${escapeHtml(trimmed)}${tail}`; - }); + // it. The character class also excludes the MARK sentinel, so a code span or + // emitted link sitting at the end of a URL is not swallowed into the href. + text = text.replace( + /(^|[\s(])(https?:\/\/[^\s<>"'\x00]+)/g, + (_whole, lead: string, href: string) => { + // The match ran on escaped text, so decode before trimming: an entity's + // own `;` would otherwise count as trailing punctuation and corrupt it. + const decoded = unescapeUrl(href); + const trimmed = trimBareUrl(decoded); + const tail = decoded.slice(trimmed.length); + const url = safeUrl(trimmed); + if (url === null) return `${lead}${href}`; + const html = `${escapeHtml(trimmed)}`; + return `${lead}${protectHtml(html)}${tail}`; + }, + ); - text = text + return restore(renderEmphasis(text)); +} + +function renderEmphasis(text: string): string { + return text .replace(/\*\*\*([^*]+)\*\*\*/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/(^|[^\w*])\*([^*\n]+)\*(?!\w)/g, '$1$2') .replace(/(^|[^\w_])__([^_\n]+)__(?!\w)/g, '$1$2') .replace(/(^|[^\w_])_([^_\n]+)_(?!\w)/g, '$1$2') .replace(/~~([^~\n]+)~~/g, '$1'); +} - return text.replace(new RegExp(`${MARK}(\\d+)${MARK}`, 'g'), (_whole, id: string) => { - return codes[Number(id)] ?? ''; - }); +/** + * An image description stays literal text (#305), but a code span in it is + * restored as its text: `` tags inside an attribute are broken HTML. + */ +function plainTextLabel(label: string, restore: (text: string) => string): string { + return restore(label).replace(/<\/?code>/g, ''); } /** Keep balanced URL parentheses; only surrounding prose belongs outside the link. */ @@ -372,7 +418,13 @@ function trimBareUrl(url: string): string { return url.slice(0, end); } -function replaceInlineLinks(text: string, options: MarkdownOptions, rel: string): string { +function replaceInlineLinks( + text: string, + options: MarkdownOptions, + rel: string, + protectHtml: (html: string) => string, + restore: (text: string) => string, +): string { let result = ''; let cursor = 0; for (let index = 0; index < text.length; index += 1) { @@ -389,14 +441,17 @@ function replaceInlineLinks(text: string, options: MarkdownOptions, rel: string) const url = safeUrl(unescapeUrl(parsed.href)); if (url === null) continue; result += text.slice(cursor, index); + let html: string; if (image) { - result += + const linkLabel = label === '' ? escapeHtml(url) : renderEmphasis(label); + html = options.noImages === true - ? `${label === '' ? escapeHtml(url) : label}` - : `${label}`; + ? `${linkLabel}` + : `${plainTextLabel(label, restore)}`; } else { - result += `${label}`; + html = `${renderEmphasis(label)}`; } + result += protectHtml(html); cursor = parsed.end + 1; index = parsed.end; } @@ -408,7 +463,11 @@ interface ParsedLinkDestination { end: number; } -function parseLinkDestination(text: string, open: number): ParsedLinkDestination | null { +function parseLinkDestination( + text: string, + open: number, + titleQuote = '"', +): ParsedLinkDestination | null { let depth = 0; let index = open + 1; while (index < text.length) { @@ -425,10 +484,10 @@ function parseLinkDestination(text: string, open: number): ParsedLinkDestination const titleStart = text.slice(index).search(/\S/); if (titleStart < 0) return null; const title = index + titleStart; - if (!text.startsWith('"', title)) return null; - const titleEnd = text.indexOf('"', title + 6); + if (!text.startsWith(titleQuote, title)) return null; + const titleEnd = text.indexOf(titleQuote, title + titleQuote.length); if (titleEnd < 0) return null; - let close = titleEnd + 6; + let close = titleEnd + titleQuote.length; while (/\s/.test(text[close] ?? '')) close += 1; return text[close] === ')' && href !== '' ? { href, end: close } : null; } else if (character === ')') { @@ -509,16 +568,35 @@ function normalizeCodeSpanBody(body: string): string { return normalized; } +/** Strip complete destinations, including the parentheses and title they contain. */ +function plainInlineLinks(source: string): string { + let text = source; + // Omit images before retaining link labels, so a linked image stays omitted. + for (const image of [true, false]) { + const parts: string[] = []; + let cursor = 0; + const pattern = image ? /!\[([^\]]*)\]\(/g : /\[([^\]]+)\]\(/g; + for (const match of text.matchAll(pattern)) { + if (match.index < cursor) continue; + const parsed = parseLinkDestination(text, match.index + match[0].length - 1, '"'); + if (parsed === null) continue; + parts.push(text.slice(cursor, match.index), image ? ' ' : (match[1] ?? '')); + cursor = parsed.end + 1; + } + parts.push(text.slice(cursor)); + text = parts.join(''); + } + return text; +} + /** Plain text, for meta descriptions, feeds, the TUI and search snippets. */ export function toPlainText(source: string, limit = 300): string { - const text = source - .replace( - /(`{3,}|~{3,})[^\n]*\n([\s\S]*?)(?:\n\s{0,3}(`{3,}|~{3,})\s*(?=\n|$)|$)/g, - (whole, open: string, _body: string, close?: string) => - close !== undefined && close[0] === open[0] && close.length >= open.length ? ' ' : whole, - ) - .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') - .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + const withoutCode = source.replace( + /(`{3,}|~{3,})[^\n]*\n([\s\S]*?)(?:\n\s{0,3}(`{3,}|~{3,})\s*(?=\n|$)|$)/g, + (whole, open: string, _body: string, close?: string) => + close !== undefined && close[0] === open[0] && close.length >= open.length ? ' ' : whole, + ); + const text = plainInlineLinks(withoutCode) // A hash in C#, an issue number or a URL fragment is text. Only remove // ATX heading markers, including optional closing hashes and quoted headings. .replace( diff --git a/src/markup/openprofile.ts b/src/markup/openprofile.ts index 9743fe3..71bc68f 100644 --- a/src/markup/openprofile.ts +++ b/src/markup/openprofile.ts @@ -134,7 +134,7 @@ function accountLines(parsed: OpenResume): string[] { for (const links of parsed.sections.filter((section) => section.kind === 'links')) { for (const line of links.markdown.split('\n')) { - const bullet = line.replace(/^\s*[-*]\s+/, '').trim(); + const bullet = line.replace(/^\s*[-*+]\s+/, '').trim(); const md = parseMarkdownLink(bullet); if (md !== null && isHttp(md.href)) { add(md.label.trim(), md.href); @@ -169,7 +169,20 @@ function operatorLines(contact: ResumeContact[]): string[] { return [`- **Name**: ${(withEmail[1] ?? '').trim()}`, `- **Email**: ${withEmail[2] ?? ''}`]; } if (item.href !== null && item.href.startsWith('mailto:')) { - return [`- **Email**: ${item.value}`]; + const recipient = item.href.slice('mailto:'.length).split('?')[0] ?? ''; + let email = recipient; + try { + email = decodeURIComponent(recipient); + } catch { + // Keep the raw URI path below; it will not be mistaken for the label. + } + if (/^[^\s@()<>]+@[^\s@()<>]+\.[^\s@()<>]+$/.test(email)) { + if (/^[^\s@()<>]+@[^\s@()<>]+\.[^\s@()<>]+$/.test(item.value)) { + return [`- **Email**: ${email}`]; + } + return [`- **Name**: ${item.value}`, `- **Email**: ${email}`]; + } + return [`- **Name**: ${item.value}`]; } return [`- **Name**: ${item.value}`]; } diff --git a/src/markup/resume.ts b/src/markup/resume.ts index c5dc6af..3b53791 100644 --- a/src/markup/resume.ts +++ b/src/markup/resume.ts @@ -186,7 +186,7 @@ function parseDocument(source: string): { resume: OpenResume; body: string } { continue; } - const h1 = /^#\s+(.+?)\s*(?:(?<=[ \t])#+)?\s*$/.exec(line); + const h1 = /^ {0,3}#\s+(.+?)\s*(?:(?<=[ \t])#+)?\s*$/.exec(line); if (h1 !== null) { if (seenH1) { warnings.push('More than one top-level heading; the first one is used as the name.'); @@ -199,7 +199,7 @@ function parseDocument(source: string): { resume: OpenResume; body: string } { continue; } - const h2 = /^##\s+(.+?)\s*(?:(?<=[ \t])#+)?\s*$/.exec(line); + const h2 = /^ {0,3}##\s+(.+?)\s*(?:(?<=[ \t])#+)?\s*$/.exec(line); if (h2 !== null) { flushSection(); inPreamble = false; @@ -208,7 +208,7 @@ function parseDocument(source: string): { resume: OpenResume; body: string } { continue; } - const h3 = /^###\s+(.+?)\s*(?:(?<=[ \t])#+)?\s*$/.exec(line); + const h3 = /^ {0,3}###\s+(.+?)\s*(?:(?<=[ \t])#+)?\s*$/.exec(line); if (h3 !== null && section !== null) { flushEntry(); const raw = (h3[1] ?? '').trim(); @@ -284,6 +284,18 @@ function parseDocument(source: string): { resume: OpenResume; body: string } { export function parseMarkdownLink(text: string): { label: string; href: string; end: number } | null { const opening = /^\[([^\]]+)\]\(/.exec(text); if (opening === null) return null; + const angleDestination = /^\[([^\]]+)\]\(<((?:\\.|[^<>\\\n])*)>(?:\s+"[^"]*")?\s*\)/.exec( + text, + ); + if (angleDestination !== null) { + const href = (angleDestination[2] ?? '').replace(/\\([()<>\\])/g, '$1'); + if (href === '') return null; + return { + label: angleDestination[1] ?? '', + href, + end: angleDestination[0].length - 1, + }; + } let href = ''; let depth = 0; for (let index = opening[0].length; index < text.length; index += 1) { @@ -302,7 +314,7 @@ export function parseMarkdownLink(text: string): { label: string; href: string; } } else if (/\s/.test(character)) { // An optional link title follows the URL; it is not part of the account. - const title = /^\s+"[^"]*"\s*\)/.exec(text.slice(index)); + const title = /^\s+(?:"[^"]*"\s*)?\)/.exec(text.slice(index)); return title === null || href === '' ? null : { label: opening[1] ?? '', href, end: index + title[0].length - 1 }; @@ -372,13 +384,16 @@ function hrefFor(value: string): string | null { /** "Full Stack Engineer (Mar 2020 - Present)" -> start, end, current. */ function parseRange(subtitle: string): { start: string | null; end: string | null; current: boolean } { const bracketed = /\(([^)]*)\)\s*$/.exec(subtitle); - const candidate = bracketed?.[1] ?? subtitle; + // Separators in an undated role (e.g. Assistant to Director) are prose, + // not date endpoints. The convention only reads a trailing bracketed range. + if (bracketed === null) return { start: null, end: null, current: false }; + const candidate = bracketed[1] ?? ''; // An en dash, an em dash and a hyphen all show up in real resumes. // Typographic dashes also appear without spaces (2019–2021). Keep // whitespace required around ASCII hyphens so ISO dates stay intact. const split = candidate.split(/\s+(?:to|-)\s+|\s*[–—]\s*/i); if (split.length < 2) { - if (bracketed !== null && candidate.trim() !== '') { + if (candidate.trim() !== '') { return { start: candidate.trim(), end: null, current: PRESENT.test(candidate) }; } return { start: null, end: null, current: false }; @@ -467,6 +482,100 @@ const EMAIL_IN_TEXT = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; const PHONE_IN_TEXT = /(?:\+\d[\d ()./-]{6,}\d|\(\d{3}\)[\d ()./-]{5,}\d|\b\d{3}[-.\s]\d{3}[-.\s]\d{4}\b)/g; +/** + * An explicit contact URI is a channel even when its recipient does not + * resemble plain prose: mailto can percent-encode @, and tel can carry an + * unformatted number. The URI boundary is checked below so balanced parentheses + * in telephone numbers or mail headers do not leave a recipient behind. + */ +const CONTACT_URI_IN_TEXT = + /\b(?:mailto|tel):/gi; + +/** Match the renderer's balanced/escaped URL parentheses, including URI headers. */ +function contactUriEnd(text: string, start: number, telephone: boolean): number { + let depth = 0; + let end = start; + for (; end < text.length; end += 1) { + const char = text[end] ?? ''; + if (/[<>[\]"`]/.test(char)) break; + if (/\s/.test(char)) { + // Visual telephone separators may contain spaces. A line break, prose, + // or an unmatched Markdown closing parenthesis ends the destination. + if (!telephone || !/[ \t]/.test(char) || !/^(?:\d|\\?\()/.test(text.slice(end + 1))) break; + } + if (char === '\\' && /[()]/.test(text[end + 1] ?? '')) { + end += 1; + continue; + } + if (char === '(') depth += 1; + if (char === ')') { + if (depth === 0) break; + depth -= 1; + } + } + // A trailing comma separates prose or a following skill; commas within + // a mail recipient list remain part of the channel and are withheld. + while (end > start && text[end - 1] === ',') end -= 1; + return end; +} + +/** Keep scheme names within project HTTP URLs from becoming contact fields. */ +function redactContactUris(text: string): string { + const urls: { start: number; end: number }[] = []; + for (const match of text.matchAll(/\bhttps?:\/\//gi)) { + let end = match.index + match[0].length; + let depth = 0; + for (; end < text.length; end += 1) { + const char = text[end] ?? ''; + if (/[\s<>"`]/.test(char)) break; + if (char === '[') { + // A bracketed IPv6 host belongs to the URL. A following Markdown + // link label begins a separate token even when there is no space. + const close = text.indexOf(']', end + 1); + if (end === match.index + match[0].length && close !== -1 + && /^[\da-f:.%]+$/i.test(text.slice(end + 1, close))) { + end = close; + continue; + } + break; + } + if (char === ']') break; + if (char === '\\' && /[()]/.test(text[end + 1] ?? '')) { + end += 1; + continue; + } + if (char === '(') depth += 1; + if (char === ')') { + if (depth === 0) break; + depth -= 1; + } + } + urls.push({ start: match.index, end }); + } + const out: string[] = []; + let cursor = 0; + for (const match of text.matchAll(CONTACT_URI_IN_TEXT)) { + const start = match.index; + if (start < cursor || urls.some((url) => start >= url.start && start < url.end)) continue; + const payload = start + match[0].length; + const end = contactUriEnd(text, payload, /^tel:/i.test(match[0])); + if (end === payload) continue; + out.push(text.slice(cursor, start), CONTACT_WITHHELD); + cursor = end; + } + out.push(text.slice(cursor)); + return out.join(''); +} + +/** Whether plain summary text contains a channel the public resume withholds. */ +export function hasContactChannel(text: string): boolean { + return ( + redactContactUris(text) !== text || + new RegExp(EMAIL_IN_TEXT.source).test(text) || + new RegExp(PHONE_IN_TEXT.source).test(text) + ); +} + /** * The contact block, minus every way to actually reach the person. * @@ -549,7 +658,7 @@ export function redactContactChannels(source: string): { markdown: string; redac // global, and a global regex's `test` advances `lastIndex` between calls, // so it returns false on matches it has already walked past. That is how a // redaction skips lines at random and still passes a one-line unit test. - const scrubbed = line + const scrubbed = redactContactUris(line) .replace(EMAIL_IN_TEXT, CONTACT_WITHHELD) .replace(PHONE_IN_TEXT, CONTACT_WITHHELD); if (scrubbed !== line) { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index abb7c54..93389ce 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -33,6 +33,17 @@ export async function handleMessage( payload: unknown, caller: Caller, info: ServerInfo, +): Promise { + const response = await dispatchMessage(payload, caller, info); + // Suppress every result and error at the boundary, while still dispatching + // notifications exactly once. Individual method branches must not reply. + return isRequest(payload) && isNotification(payload) ? null : response; +} + +async function dispatchMessage( + payload: unknown, + caller: Caller, + info: ServerInfo, ): Promise { if (!isRequest(payload)) { return failure(null, INVALID_PARAMS, 'Not a JSON-RPC 2.0 request.'); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 70ad9eb..f574044 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -449,6 +449,24 @@ export const TOOLS: ToolDefinition[] = [ watches: { type: 'boolean', description: 'List the watches instead of the notifications.' }, }), }, + { + name: 'credits', + title: 'Credit and invites', + description: + 'Your credit balance on this board (US cents) and where it came from. Pass invites: true for your invites instead: how many are left of three, which were accepted, and your open ?ref= link. Pass createInvite: true to make an invite; its link is returned once.', + inputSchema: object({ + invites: { type: 'boolean', description: 'List invites instead of credit.' }, + createInvite: { type: 'boolean', description: 'Make an invite and return its link.' }, + note: string('Who the new invite is for. Only you see it.'), + }), + }, + { + name: 'redeem_credit_code', + title: 'Redeem a credit code', + description: + 'Redeem a credit code (AJ-XXXX-XXXX-XXXX-XXXX) on this account. Case, spaces and dashes do not matter. Each code works once per account.', + inputSchema: object({ code: string('The code.') }, ['code']), + }, { name: 'rankings', title: 'Rankings', @@ -506,6 +524,7 @@ export async function callTool( 'seniority', 'agentPolicy', 'tag', + 'tags', 'salaryMin', 'limit', 'offset', @@ -1067,12 +1086,51 @@ export async function callTool( if (response.status === 401) return toolError(signInFirst(caller)); if (response.status !== 200) return toolError(message(response.body, 'Could not read notifications.')); - if (args['markRead'] === true) await caller.call('POST', '/api/v1/notifications/read', {}); + if (args['markRead'] === true) { + const marked = await caller.call('POST', '/api/v1/notifications/read', {}); + if (marked.status !== 200) { + return toolError(message(marked.body, 'Could not mark notifications read.')); + } + } const page = response.body as { items?: unknown[] }; if ((page.items ?? []).length === 0) return text('No notifications.', response.body); return text(JSON.stringify(response.body, null, 2), response.body); } + case 'credits': { + if (args['createInvite'] === true) { + const response = await caller.call('POST', '/api/v1/invites', { + note: typeof args['note'] === 'string' ? args['note'] : '', + }); + if (response.status === 401) return toolError(signInFirst(caller)); + if (response.status !== 201) + return toolError(message(response.body, 'No invite was made.')); + const made = response.body as { url?: string; quota?: { left?: number } }; + return text( + `Invite link (shown once): ${made.url ?? ''}. ${made.quota?.left ?? 0} left.`, + response.body, + ); + } + const response = await caller.call( + 'GET', + args['invites'] === true ? '/api/v1/invites' : '/api/v1/credits', + ); + if (response.status === 401) return toolError(signInFirst(caller)); + if (response.status !== 200) return toolError(message(response.body, 'Could not read that.')); + return text(JSON.stringify(response.body, null, 2), response.body); + } + + case 'redeem_credit_code': { + const response = await caller.call('POST', '/api/v1/credits/redeem', { + code: typeof args['code'] === 'string' ? args['code'] : '', + }); + if (response.status === 401) return toolError(signInFirst(caller)); + if (response.status !== 200) + return toolError(message(response.body, 'That code was not redeemed.')); + const done = response.body as { balance?: string; addedCents?: number }; + return text(`Redeemed. Balance ${done.balance ?? ''}.`, response.body); + } + case 'rankings': { const response = await caller.call( 'GET', diff --git a/src/schema/pay.ts b/src/schema/pay.ts index 3da5e3b..213c3b7 100644 --- a/src/schema/pay.ts +++ b/src/schema/pay.ts @@ -205,7 +205,7 @@ function codeFrom(raw: string | undefined): string | null { return null; } -function readMoney(text: string, at: number): Money | null { +function readMoney(text: string, at: number, allowSuffixWords = false): Money | null { MONEY.lastIndex = at; const match = MONEY.exec(text); if (match === null) return null; @@ -225,7 +225,12 @@ function readMoney(text: string, at: number): Money | null { currency ??= code; } if (codeAfter !== undefined) { - const code = codeFrom(codeAfter); + // Only retry ambiguous uppercase words as prose after the ordinary + // currency interpretation failed, and only for a complete pay suffix. + const suffix = text.slice(end - codeAfter.length).trim(); + const suffixWord = allowSuffixWords && + (FLAT.test(suffix) || PERIOD_ONLY.test(suffix) || PER.test(suffix)); + const code = suffixWord ? null : codeFrom(codeAfter); if (code !== null) { // Every symbol or code attached to one amount must agree. // Otherwise "USD 100 EUR" or "$100 EUR" silently loses a currency. @@ -342,7 +347,9 @@ export function readPayLine(input: unknown): { line: PayLine; method: string | n text = raw.slice(0, settled.index).trim(); } } - const line = parseLine(text); + const ordinary = parseLine(text); + const retry = typeof ordinary === 'string' ? parseLine(text, true) : ordinary; + const line = typeof retry === 'string' ? ordinary : retry; return typeof line === 'string' ? line : { line, method }; } @@ -354,7 +361,7 @@ function isRail(value: string): boolean { ); } -function parseLine(text: string): PayLine | string { +function parseLine(text: string, allowSuffixWords = false): PayLine | string { const share = parseRevenueShare(text); if (share !== null) return share; @@ -367,7 +374,7 @@ function parseLine(text: string): PayLine | string { cursor = leadMatch[0].length; } - const first = readMoney(text, cursor); + const first = readMoney(text, cursor, allowSuffixWords); if (first === null) { return `Could not find an amount in "${text}". Write it like ${PAY_LINE_EXAMPLES}.`; } @@ -378,7 +385,7 @@ function parseLine(text: string): PayLine | string { dash.lastIndex = cursor; const dashMatch = dash.exec(text); if (dashMatch !== null) { - second = readMoney(text, cursor + dashMatch[0].length); + second = readMoney(text, cursor + dashMatch[0].length, allowSuffixWords); if (second !== null) cursor = second.end; } @@ -622,21 +629,39 @@ export function normaliseMethod(value: unknown): string | null { return text; } +function payAmount(value: unknown): { value: number | null; error: string | null } { + if (value === null || value === undefined || (typeof value === 'string' && value.trim() === '')) { + return { value: null, error: null }; + } + // JSON arrays such as [25] stringify to "25"; they are not amounts. + if (typeof value !== 'number' && typeof value !== 'string') { + return { value: null, error: 'A pay amount must be a non-negative number.' }; + } + const raw = typeof value === 'number' ? value : String(value).replace(/[,_$]/g, ''); + if (typeof raw === 'string' && raw.trim() === '') { + return { value: null, error: 'A pay amount must be a non-negative number.' }; + } + const amount = typeof raw === 'number' ? raw : Number(raw); + if (!Number.isFinite(amount) || amount < 0) { + return { value: null, error: 'A pay amount must be a non-negative number.' }; + } + return { value: amount, error: null }; +} + function lineFromObject(value: Record): PayLine | string { const type = value['type']; if (!isPayType(type)) { return `A pay line needs a type from ${PAY_TYPES.join(', ')}, or a "text" such as "$0.25 per task".`; } - const number = (raw: unknown): number | null => { - if (raw === null || raw === undefined || raw === '') return null; - const n = typeof raw === 'number' ? raw : Number(String(raw).replace(/[,_$]/g, '')); - return Number.isFinite(n) && n >= 0 ? n : null; - }; // `amount` is a single figure: ugig writes a fixed price as min = max, and // an object with only `min` is an open floor, printed "From". - const amount = number(value['amount']); - const min = amount ?? number(value['min']); - const max = amount ?? number(value['max']); + const amount = payAmount(value['amount']); + const suppliedMin = payAmount(value['min']); + const suppliedMax = payAmount(value['max']); + const problem = amount.error ?? suppliedMin.error ?? suppliedMax.error; + if (problem !== null) return problem; + const min = amount.value ?? suppliedMin.value; + const max = amount.value ?? suppliedMax.value; if (min !== null && max !== null && max < min) return 'The top of a pay range is below the bottom of it.'; if (type === 'revenue_share' && ((min ?? 0) > 100 || (max ?? 0) > 100)) { return 'A revenue share is at most 100%.'; @@ -740,13 +765,12 @@ export function normalisePay(input: Record): Pay | string { // The flat fields, when nothing newer was sent. if (lines.length === 0 && !saidUnpaid) { - const money = (value: unknown): number | null => { - if (value === null || value === undefined || value === '') return null; - const n = typeof value === 'number' ? value : Number(String(value).replace(/[,_$]/g, '')); - return Number.isFinite(n) && n >= 0 ? n : null; - }; - const min = money(input['salaryMin']); - const max = money(input['salaryMax']); + const suppliedMin = payAmount(input['salaryMin']); + const suppliedMax = payAmount(input['salaryMax']); + const problem = suppliedMin.error ?? suppliedMax.error; + if (problem !== null) return problem; + const min = suppliedMin.value; + const max = suppliedMax.value; if (min !== null || max !== null) { if (min !== null && max !== null && max < min) { return 'The top of the salary range is below the bottom of it.'; diff --git a/src/schema/query.ts b/src/schema/query.ts index e090ee4..ec06b5e 100644 --- a/src/schema/query.ts +++ b/src/schema/query.ts @@ -58,7 +58,14 @@ export const EMPTY_QUERY: JobQuery = { function one(params: URLSearchParams, key: string): string | null { const value = params.get(key); if (value === null) return null; - const trimmed = value.trim(); + // A NUL byte is not text: PostgreSQL refuses it outright, so `?q=%00` and + // `?org=%00` reached the LIKE pattern and answered 500 on every surface + // that shares this parse - the page, the API, MCP, the CLI and federated + // search at once. Drop the byte rather than reject the query, the same way + // an unknown value is dropped. Only NUL: the other control characters + // travel fine, and a cap here would turn a too-long numeric filter into a + // finite number instead of the fallback the integer parse owes it. + const trimmed = value.replace(/\0/g, '').trim(); return trimmed === '' ? null : trimmed; } @@ -93,12 +100,16 @@ export function parseQuery(params: URLSearchParams): JobQuery { agentPolicy: isAgentPolicy(agentPolicy) ? agentPolicy : null, // `tag=a&tag=b` and `tags=a,b` mean the same thing. The second is what a // badge links to and what a person types; the first is what the CLI's - // repeatable --tag produces. - tags: [...params.getAll('tag'), ...params.getAll('tags')] - .flatMap((value) => value.split(',')) - .map((value) => value.trim().toLowerCase()) - .filter((value) => value !== '') - .slice(0, 10), + // repeatable --tag produces. A tag named in both forms is still one + // filter, so repeats are dropped before the cap rather than spending it. + tags: [ + ...new Set( + [...params.getAll('tag'), ...params.getAll('tags')] + .flatMap((value) => value.split(',')) + .map((value) => value.replace(/\0/g, '').trim().toLowerCase()) + .filter((value) => value !== ''), + ), + ].slice(0, 10), salaryMin: salaryMin === null ? null : integer(salaryMin, 0, 0, 100_000_000) || null, org: one(params, 'org'), sort: sort !== null && (SORTS as readonly string[]).includes(sort) ? (sort as Sort) : 'recent', diff --git a/src/server/app.tsx b/src/server/app.tsx index 14011e5..585e365 100644 --- a/src/server/app.tsx +++ b/src/server/app.tsx @@ -8,6 +8,9 @@ */ import { Hono } from 'hono'; +import { SESSION_COOKIE } from '../core/auth.ts'; +import { createGateway } from '@profullstack/x402-gateway'; +import { x402Gateway } from '@profullstack/x402-gateway/hono'; import type pg from 'pg'; import type { Config } from '../config.ts'; import { apiRoutes } from './routes/api.ts'; @@ -15,6 +18,8 @@ import { settingsRoutes } from './routes/settings.ts'; import { trackerRoutes } from './routes/tracker.tsx'; import { agentRoutes } from './routes/agents.tsx'; import { watchRoutes } from './routes/watches.tsx'; +import { creditRoutes } from './routes/credits.tsx'; +import { trackRef } from './signup-credits.ts'; import { rankingRoutes } from './routes/rankings.tsx'; import { landingRoutes } from './routes/landing.tsx'; import { createRankings } from '../core/rankings.ts'; @@ -43,6 +48,28 @@ export function createApp( const deps: Deps = { pool, config, mailer, coinpay, rankings: createRankings(pool, config) }; const app = new Hono(); + // The crawl door, first: a training crawler, headless browser or SEO scraper is + // sold a day pass ($1 over x402, or any coin / PayPal through CoinPay's hosted + // checkout on /crawl) before the board spends a database query on it. People, + // verified search crawlers and the AI chat/search fetchers pass through, as does + // anyone signed in. Ahead of the security headers, whose form-action would stop + // the /crawl/checkout redirect to CoinPay. + if (config.crawl) { + const crawlGateway = createGateway({ + siteUrl: config.publicUrl, + siteName: config.boardName, + coinpay: { apiKey: config.crawl.apiKey }, + payTo: config.crawl.payTo, + contact: 'mailto:anthony@profullstack.com', + // /i/ is an invite link: a chat app's link preview must not be sold a + // crawl pass for it, and the page spends nothing. + openPaths: ['/api/health', '/llms.txt', '/.well-known/', '/i/'], + exempt: (request) => + new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=`).test(request.headers.get('cookie') ?? ''), + }); + app.use('*', x402Gateway(crawlGateway)); + } + app.use('*', withViewer(deps)); // A payer is sent from a form on this board to CoinPay's hosted pay page, // and the policy has to say so or the browser stops the redirect. @@ -51,6 +78,28 @@ export function createApp( securityHeaders({ formActions: config.coinpay === null ? [] : [config.coinpay.url] }), ); app.use('/api/*', apiCors()); + // First-touch ?ref= affiliate attribution, kept until a sign-up spends it. + app.use('*', trackRef()); + + // Liveness for the status page: the process answers and the database + // answers within three seconds. Nothing about why it failed leaves the box. + app.get('/api/health', async (c) => { + c.header('Cache-Control', 'no-store'); + let timer: ReturnType | undefined; + try { + await Promise.race([ + pool.query('select 1'), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('timeout')), 3000); + }), + ]); + return c.json({ status: 'ok', db: 'ok' }); + } catch { + return c.json({ status: 'error', db: 'down' }, 503); + } finally { + clearTimeout(timer); + } + }); // Settings sync (@profullstack/synconfig): the boards a member uses, on // every machine. Mounted before the API router, whose catch-all answers @@ -59,6 +108,7 @@ export function createApp( app.route('/', trackerRoutes()); app.route('/', agentRoutes()); app.route('/', watchRoutes()); + app.route('/', creditRoutes()); app.route('/', rankingRoutes()); app.route('/api/v1', apiRoutes()); // The MCP tools call this same app, so the getter is resolved lazily: the diff --git a/src/server/middleware.ts b/src/server/middleware.ts index 5a5805a..c414539 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -107,6 +107,9 @@ export function securityHeaders( `frame-ancestors 'none'`, `object-src 'none'`, `script-src 'self' https://crawlproof.com`, + // The one ad unit is a cross-origin CrawlProof frame (/api/ads/frame), so + // it costs this entry and nothing else: no script, no style, no storage. + `frame-src https://crawlproof.com`, `style-src 'self' 'unsafe-inline'`, // Employer logos and avatars come from wherever the employer hosts them. // 'self' has to be listed explicitly: omitting it works in production and diff --git a/src/server/routes/api.ts b/src/server/routes/api.ts index 53e461d..b6f9a1f 100644 --- a/src/server/routes/api.ts +++ b/src/server/routes/api.ts @@ -28,7 +28,10 @@ import { startMagicLink, type Viewer, } from '../../core/auth.ts'; +import { canonicalInvite, cleanRef } from '../../core/credits.ts'; import { + applicationPage, + countApplications, createApplication, decideApplication, listApplications, @@ -44,11 +47,12 @@ import { insertJob, getJobBySlug, getJobBySourceUrl, + listJobsForUser, normaliseInput, publishProblem, searchJobs, setStatus, - editJob, + patchJob, updateJobFromImport, } from '../../core/jobs.ts'; import { extractJob, JobImportProblem, type ImportedJob } from '../../core/import-job.ts'; @@ -74,6 +78,7 @@ import { updateResume, } from '../../core/resumes.ts'; import { importDocument, ImportProblem, MAX_UPLOAD_BYTES } from '../../core/import.ts'; +import { profileHandlers } from '../../core/profile.ts'; import { deliverMagicLink } from '../../core/mail.ts'; import { resumeForViewer, tagsFrom, toCandidateSummary, withTags } from '../../core/candidates.ts'; import { @@ -251,6 +256,58 @@ function payPatch(body: Record, job: Job): Record = { + title: 'title', + description: 'description', + employmentType: 'employmentType', + workplace: 'workplace', + seniority: 'seniority', + location: 'location', + remoteRegions: 'remoteRegions', + tags: 'tags', + stack: 'stack', + requirements: 'requirements', + responsibilities: 'responsibilities', + agentPolicy: 'agentPolicy', + expiresAt: 'expiresAt', +}; + +/** Every body key that touches the pay group. */ +const JOB_PAY_KEYS = [ + 'pay', + 'payLines', + 'salaryMin', + 'salaryMax', + 'salaryPeriod', + 'salaryCurrency', + 'salaryUnpaid', + 'unpaid', + 'payUnpaid', + 'payMethod', + 'paymentMethod', + 'paymentCoin', + 'payEquity', + 'salaryEquity', + 'equity', +]; + +/** + * The fields a PATCH actually sent. `undefined` is "leave it"; an explicit + * `null` still counts as sent so a nullable field like `expiresAt` can be + * cleared. Only these columns move — two members editing different fields + * at once no longer overwrite each other, and an edit cannot silently + * clear a field the merge never carried. + */ +function sentJobFields(body: Record): Set { + const fields = new Set(); + for (const [key, field] of Object.entries(JOB_PATCH_FIELDS)) { + if (body[key] !== undefined) fields.add(field); + } + if (JOB_PAY_KEYS.some((key) => body[key] !== undefined)) fields.add('pay'); + return fields; +} + export function apiRoutes(): Hono { const api = new Hono(); @@ -357,19 +414,12 @@ export function apiRoutes(): Hono { ); } - const application = await createApplication(pool, job.id, validated.value, { submit }); - if (resumeMarkdown !== null || viewer !== null) { - await pool.query( - `update applications set resume_markdown = $2, resume_title = $3, user_id = $4 - where id = $1`, - [ - application.id, - resumeMarkdown, - resumeMarkdown === null ? null : (parseResume(resumeMarkdown).name ?? 'Resume'), - viewer?.id ?? null, - ], - ); - } + const application = await createApplication(pool, job.id, validated.value, { + submit, + userId: viewer?.id ?? null, + resumeMarkdown, + resumeTitle: resumeMarkdown === null ? null : (parseResume(resumeMarkdown).name ?? 'Resume'), + }); return c.json( { @@ -577,11 +627,14 @@ export function apiRoutes(): Hono { workplace: body['workplace'] ?? job.workplace, seniority: body['seniority'] ?? job.seniority ?? undefined, location: body['location'] ?? job.location ?? undefined, + remoteRegions: body['remoteRegions'] ?? job.remoteRegions, tags: body['tags'] ?? job.tags, stack: body['stack'] ?? job.stack, requirements: body['requirements'] ?? job.requirements, responsibilities: body['responsibilities'] ?? job.responsibilities, agentPolicy: body['agentPolicy'] ?? job.agentPolicy, + applySchema: Object.hasOwn(body, 'applySchema') ? body['applySchema'] : job.apply.schema, + expiresAt: Object.hasOwn(body, 'expiresAt') ? body['expiresAt'] : job.expiresAt, ...payPatch(body, job), }, job.org.id, @@ -595,7 +648,7 @@ export function apiRoutes(): Hono { if (problem !== null) return fail(c, 400, 'pay_required', problem); } - const updated = await editJob(pool, job.id, merged); + const updated = await patchJob(pool, job.id, merged, sentJobFields(body)); return c.json({ job: updated ?? job }); }); @@ -634,7 +687,10 @@ export function apiRoutes(): Hono { return fail(c, 403, 'not_a_member', `You are not a member of ${job.org.name}.`); } - const applications = await listApplications(pool, job.id); + const { limit, offset: requestedOffset } = applicationPage(new URL(c.req.url).searchParams); + const total = await countApplications(pool, job.id); + const offset = Math.min(requestedOffset, Math.max(0, total - 1)); + const applications = await listApplications(pool, job.id, limit, offset); const withResumes = await Promise.all( applications.map(async (application) => { const row = await pool.query<{ @@ -650,7 +706,7 @@ export function apiRoutes(): Hono { }; }), ); - return c.json({ job: job.slug, items: withResumes, total: withResumes.length }); + return c.json({ job: job.slug, items: withResumes, total, limit, offset }); }); /** @@ -707,7 +763,15 @@ export function apiRoutes(): Hono { const viewer = viewerOf(c); if (viewer === null) return fail(c, 401, 'unauthenticated', 'Sign in first.'); const sent = await submitApplication(pool, c.req.param('id'), viewer.id); - if (!sent) { + if (sent === 'job_not_open') { + return fail( + c, + 409, + 'job_not_open', + 'This listing is no longer accepting applications. Your draft is still saved.', + ); + } + if (sent === 'not_found') { return fail( c, 404, @@ -834,6 +898,19 @@ export function apiRoutes(): Hono { }); }); + // The shared profile (@profullstack/profile): names, company, title, LinkedIn. + api.get('/me/profile', (c) => profileHandlers(c.get('deps').pool).GET(c.req.raw, c)); + api.put('/me/profile', (c) => profileHandlers(c.get('deps').pool).PUT(c.req.raw, c)); + api.patch('/me/profile', (c) => profileHandlers(c.get('deps').pool).PATCH(c.req.raw, c)); + + api.get('/me/jobs', async (c) => { + const viewer = viewerOf(c); + if (viewer === null) return fail(c, 401, 'unauthenticated', 'Sign in to read your listings.'); + c.header('cache-control', 'private, no-store'); + const items = await listJobsForUser(c.get('deps').pool, viewer.id); + return c.json({ items, total: items.length }); + }); + api.get('/resumes', async (c) => { const { pool } = c.get('deps'); const viewer = viewerOf(c); @@ -1112,6 +1189,7 @@ export function apiRoutes(): Hono { const posted = await postUpdate(pool, viewer.id, target, { body: body['body'], link: body['link'], + board: config.publicUrl, }); if (typeof posted === 'string') { // 429 when it is the rate limit and 400 when it is the update itself: @@ -1344,7 +1422,12 @@ export function apiRoutes(): Hono { // terminal. safeRedirect keeps this to same-origin paths: an open redirect // on a sign-in link is a phish. const redirect = safeRedirect(body['redirect']); - const link = await startMagicLink(pool, email, redirect); + // An invite code or affiliate code, when the terminal signing up has one. + // Applied when the link is spent, and only if that makes a new account. + const link = await startMagicLink(pool, email, redirect, null, { + invite: canonicalInvite(body['invite']), + ref: cleanRef(body['ref']), + }); const url = `${config.publicUrl}/auth/callback?token=${link.token}`; const delivered = await deliverMagicLink({ mailer, @@ -1839,6 +1922,10 @@ async function resolveResume( const id = body['resumeId']; if (typeof id === 'string' && id !== '') { + // A string that is not a uuid can never name a resume, and Postgres + // raises `invalid input syntax for type uuid` rather than match nothing - + // without the check a mistyped id is a 500, not a miss. + if (!isUuid(id)) return { error: 'That resume is not shared, or does not exist.' }; // A shared id is accepted from anyone, which is what a share link is for; // a private one only from its owner. const shared = await getSharedResume(pool, id); diff --git a/src/server/routes/credits.tsx b/src/server/routes/credits.tsx new file mode 100644 index 0000000..c526158 --- /dev/null +++ b/src/server/routes/credits.tsx @@ -0,0 +1,237 @@ +/** + * Credit codes and invites: the API, the /invites page, the /me redeem form + * and the /i/ landing page. + * + * GET /api/v1/credits balance and recent history + * POST /api/v1/credits/redeem {code} redeem a credit code + * GET /api/v1/invites quota, invites, affiliate link + * POST /api/v1/invites {note} make an invite (the code is shown once) + * POST /api/v1/admin/credit-codes mint a code (a browser-session admin only) + */ + +import { Hono, type Context } from 'hono'; +import type { AppEnv } from '../deps.ts'; +import { requireAdmin } from '../../core/auth.ts'; +import { + canonicalInvite, + cleanCode, + createInvite, + creditSummary, + formatCents, + inviteSummary, + mintCreditCode, + redeemCreditCode, +} from '../../core/credits.ts'; +import { Layout } from '../../views/layout.tsx'; +import { InviteLandingPage, InvitesPage } from '../../views/credits.tsx'; +import { formOf, requireViewer, shell } from './pages.tsx'; +import { rememberInvite } from '../signup-credits.ts'; + +type Ctx = Context; + +/** + * The origin every link on these pages is built from. The configured public + * URL, unless it was left at its localhost default, in which case the host the + * request came in on: an invite link that says localhost is a dead link. + */ +export function originOf(c: Ctx): string { + const configured = c.get('deps').config.publicUrl; + if (!/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(configured)) return configured; + const url = new URL(c.req.url); + const host = c.req.header('x-forwarded-host') ?? c.req.header('host') ?? url.host; + const proto = + c.req.header('x-forwarded-proto')?.split(',')[0]?.trim() || url.protocol.replace(':', ''); + return `${proto}://${host}`; +} + +function unauthenticated(c: Ctx): Response { + return c.json({ error: { code: 'unauthenticated', message: 'Sign in first.' } }, 401); +} + +async function body(c: Ctx): Promise> { + const type = c.req.header('content-type') ?? ''; + try { + if (type.includes('application/json')) { + const parsed = (await c.req.json()) as unknown; + return typeof parsed === 'object' && parsed !== null + ? (parsed as Record) + : {}; + } + return (await c.req.parseBody()) as Record; + } catch { + return {}; + } +} + +function bonusLabel(c: Ctx): string | null { + const cents = c.get('deps').config.invites?.bonusCents ?? 100; + return cents > 0 ? formatCents(cents) : null; +} + +export function creditRoutes(): Hono { + const routes = new Hono(); + + // --- API --------------------------------------------------------------- + + routes.get('/api/v1/credits', async (c) => { + const viewer = c.get('viewer'); + if (viewer === null) return unauthenticated(c); + return c.json(await creditSummary(c.get('deps').pool, viewer.id)); + }); + + routes.post('/api/v1/credits/redeem', async (c) => { + const viewer = c.get('viewer'); + if (viewer === null) return unauthenticated(c); + const code = cleanCode((await body(c))['code']); + if (code === null) { + return c.json( + { error: { code: 'invalid', message: 'Send the code as {"code": "AJ-..."}.' } }, + 400, + ); + } + const result = await redeemCreditCode(c.get('deps').pool, viewer.id, code); + if (!result.ok) return c.json({ error: { code: result.reason, message: result.message } }, 400); + return c.json({ + ok: true, + addedCents: result.cents, + balanceCents: result.balanceCents, + balance: formatCents(result.balanceCents), + }); + }); + + routes.get('/api/v1/invites', async (c) => { + const viewer = c.get('viewer'); + if (viewer === null) return unauthenticated(c); + return c.json({ + ...(await inviteSummary(c.get('deps').pool, viewer.id, originOf(c))), + bonusCents: c.get('deps').config.invites?.bonusCents ?? 100, + }); + }); + + routes.post('/api/v1/invites', async (c) => { + const viewer = c.get('viewer'); + if (viewer === null) return unauthenticated(c); + const note = (await body(c))['note']; + const result = await createInvite( + c.get('deps').pool, + viewer.id, + typeof note === 'string' ? note : '', + originOf(c), + ); + if (!result.ok) { + return c.json( + { error: { code: 'no_invites_left', message: 'Every invite on this account is used.' } }, + 409, + ); + } + return c.json(result, 201); + }); + + routes.post('/api/v1/admin/credit-codes', async (c) => { + const viewer = c.get('viewer'); + if (!requireAdmin(viewer)) { + return c.json( + { error: { code: 'forbidden', message: 'Administrators only, from a browser session.' } }, + 403, + ); + } + const input = await body(c); + const days = input['expiresInDays']; + try { + const minted = await mintCreditCode(c.get('deps').pool, { + cents: Number(input['cents']), + label: typeof input['label'] === 'string' ? input['label'] : '', + maxRedemptions: input['maxRedemptions'] === undefined ? 1 : Number(input['maxRedemptions']), + expiresInDays: days === undefined || days === null ? null : Number(days), + createdBy: viewer.email, + }); + return c.json( + { + code: minted.code, + id: minted.id, + label: minted.label, + cents: minted.grant.amount, + maxRedemptions: minted.maxRedemptions, + expiresAt: minted.expiresAt === null ? null : new Date(minted.expiresAt).toISOString(), + }, + 201, + ); + } catch (error) { + return c.json( + { error: { code: 'invalid', message: error instanceof Error ? error.message : 'invalid' } }, + 400, + ); + } + }); + + // --- pages ------------------------------------------------------------- + + routes.post('/me/redeem', async (c) => { + const viewer = requireViewer(c); + if (viewer instanceof Response) return viewer; + const code = cleanCode((await formOf(c))['code']); + if (code === null) return c.redirect('/me?credit=invalid#credits', 303); + const result = await redeemCreditCode(c.get('deps').pool, viewer.id, code); + return c.redirect( + result.ok + ? `/me?credit=added¢s=${result.cents}#credits` + : `/me?credit=${result.reason}#credits`, + 303, + ); + }); + + routes.get('/invites', async (c) => { + const viewer = requireViewer(c); + if (viewer instanceof Response) return viewer; + const summary = await inviteSummary(c.get('deps').pool, viewer.id, originOf(c)); + return c.html( + + + , + ); + }); + + routes.post('/invites', async (c) => { + const viewer = requireViewer(c); + if (viewer instanceof Response) return viewer; + const { pool } = c.get('deps'); + const form = await formOf(c); + const result = await createInvite(pool, viewer.id, form['note'] ?? '', originOf(c)); + const summary = await inviteSummary(pool, viewer.id, originOf(c)); + return c.html( + + + , + result.ok ? 201 : 409, + ); + }); + + /** + * An invite link. It only remembers the code: the invite is spent when the + * new account's magic link is, so a link preview fetching this page costs + * nobody anything. Open to crawlers (see the gateway's openPaths). + */ + routes.get('/i/:code', (c) => { + const code = canonicalInvite(c.req.param('code')); + const signedIn = c.get('viewer') !== null; + if (code !== null && !signedIn) rememberInvite(c, code); + return c.html( + + + , + code === null ? 404 : 200, + ); + }); + + return routes; +} diff --git a/src/server/routes/discovery.ts b/src/server/routes/discovery.ts index 27d3944..495da1f 100644 --- a/src/server/routes/discovery.ts +++ b/src/server/routes/discovery.ts @@ -35,6 +35,9 @@ import { toPlainText } from '../../markup/markdown.ts'; import { descriptorFor } from './descriptor.ts'; import type { AppEnv } from '../deps.ts'; +/** Ring member slugs the ring assigned that are not simply the host with dots as dashes. */ +const RING_SLUGS: Record = { 'agenticjobs.work': 'agenticjobs-work-3' }; + function publicDir(): string { // dist/server/routes/discovery.js -> package root -> web/public return join(dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))), 'web', 'public'); @@ -45,6 +48,7 @@ const MIME: Record = { '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', + '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json', '.txt': 'text/plain; charset=utf-8', '.json': 'application/json', @@ -184,6 +188,25 @@ export function discoveryRoutes(): Hono { } }); + // Browsers and iOS ask the root for these whatever the page declares, so a + // missing one is a 404 in every access log and a blank tile on a home screen. + for (const [path, file] of [ + ['/favicon.ico', 'favicon.ico'], + ['/apple-touch-icon.png', 'apple-touch-icon.png'], + ['/apple-touch-icon-precomposed.png', 'apple-touch-icon.png'], + ] as const) { + routes.get(path, async (c) => { + try { + return c.body(await readFile(join(publicDir(), file)), 200, { + 'content-type': MIME[extname(file)]!, + 'cache-control': 'public, max-age=86400', + }); + } catch { + return c.notFound(); + } + }); + } + /** * The installer, at the root so the curl line is short enough to read aloud. * @@ -217,13 +240,19 @@ export function discoveryRoutes(): Hono { const { pool, config } = c.get('deps'); const query = { ...parseQuery(new URL(c.req.url).searchParams), limit: 100 }; const page = await searchJobs(pool, query); + const selfParams = queryToParams(query); + // This feed always serves 100 items, regardless of the requested limit. + // Preserve the effective filters and offset so a reader subscribing from + // a filtered URL does not silently switch to the whole board. + selfParams.delete('limit'); + const selfSearch = selfParams.toString(); // JSON Feed, so an ordinary feed reader can follow a job board. return c.json( { version: 'https://jsonfeed.org/version/1.1', title: `${config.boardName} - open roles`, home_page_url: config.publicUrl, - feed_url: `${config.publicUrl}/jobs.json`, + feed_url: `${config.publicUrl}/jobs.json${selfSearch === '' ? '' : `?${selfSearch}`}`, description: config.boardTagline, items: page.items.map((job) => ({ id: `${config.publicUrl}/jobs/${job.slug}`, @@ -444,7 +473,11 @@ export function discoveryRoutes(): Hono { // company matches "remote". const url = new URL(c.req.url); const query = parseQuery(url.searchParams); - const filtered = url.search !== '' && url.search !== '?'; + // `limit`, `offset` and `sort` change a search page, not this site-wide + // subscription. Only a filter that changes the feed's items makes it a + // filtered feed that should omit employers and updates. + const narrowed = describeQuery(query); + const filtered = narrowed !== null; // Tags are the only filter that means the same thing on both halves of the // board. Workplace, employment type, seniority, agent policy, a salary // floor and an employer are questions about a job, and a person cannot @@ -466,7 +499,7 @@ export function discoveryRoutes(): Hono { ]); return rss(c, { - title: filtered ? `${config.boardName}: ${url.search.slice(1)}` : config.boardName, + title: filtered ? `${config.boardName}: ${narrowed}` : config.boardName, link: config.publicUrl + url.search, description: config.boardTagline, self: `${config.publicUrl}/feed.rss${url.search}`, @@ -515,7 +548,9 @@ export function discoveryRoutes(): Hono { const months = await pool.query<{ month: string; latest: string }>( `select to_char(published_at, 'YYYY-MM') as month, max(published_at)::text as latest from jobs - where status = 'published' and published_at is not null + where status = 'published' + and published_at is not null and published_at <= now() + and (expires_at is null or expires_at > now()) group by 1 order by 1 desc`, ); const entries = months.rows @@ -560,6 +595,7 @@ export function discoveryRoutes(): Hono { const jobs = await pool.query<{ slug: string; updated_at: string }>( `select slug, updated_at from jobs where status = 'published' and published_at is not null + and published_at <= now() and (expires_at is null or expires_at > now()) and to_char(published_at, 'YYYY-MM') = $1 order by published_at desc limit 50000`, [name], @@ -868,12 +904,13 @@ export function discoveryRoutes(): Hono { start_url: '/', scope: '/', display: 'standalone', - background_color: '#ffffff', - theme_color: '#111318', + background_color: '#f6f4ed', + theme_color: '#1f5c45', icons: [ { src: '/assets/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }, { src: '/assets/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: '/assets/icon-512.png', sizes: '512x512', type: 'image/png' }, + { src: '/assets/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }, ], shortcuts: [ { name: 'Post a job', url: '/post' }, @@ -926,6 +963,28 @@ export function discoveryRoutes(): Hono { }); }); + /** + * The OpenWebring descriptor (rssamplifier.com/openwebring): this board's + * membership in the Profullstack ring, whose footer links every page carries. + * The member slug is the host with dots as dashes, except where the ring + * assigned another one. + */ + routes.get('/.well-known/openwebring.json', (c) => { + const { config } = c.get('deps'); + const host = new URL(config.publicUrl).hostname; + const slug = RING_SLUGS[host] ?? host.replaceAll('.', '-'); + return c.json( + { + openwebring: '0.1', + site: { url: `${config.publicUrl.replace(/\/+$/, '')}/`, name: config.boardName }, + made_by: 'both', + rings: [{ ring: 'https://rssamplifier.com/ring/profullstack', slug }], + }, + 200, + { 'cache-control': 'public, max-age=300' }, + ); + }); + /** * The OpenAccess descriptor (logicsrc.com/openaccess): how a client links * this board over OAuth 2.1 + PKCE. A static file, so it is served from diff --git a/src/server/routes/inbox.tsx b/src/server/routes/inbox.tsx index afb6de7..58b8d81 100644 --- a/src/server/routes/inbox.tsx +++ b/src/server/routes/inbox.tsx @@ -68,6 +68,7 @@ export function inboxRoutes(): Hono { const { pool } = c.get('deps'); const candidate = (input['candidate'] ?? '').trim(); const employer = (input['employer'] ?? '').trim(); + if (candidate !== '' && employer !== '') return null; if (candidate !== '') { const resume = await getPublicResume(pool, candidate); if (resume === null) return null; diff --git a/src/server/routes/openapi.ts b/src/server/routes/openapi.ts index 956a9b1..0ebb8be 100644 --- a/src/server/routes/openapi.ts +++ b/src/server/routes/openapi.ts @@ -73,6 +73,11 @@ export function openApiDocument(config: Config): Record { description: 'Searches to be told about. A published listing matching one becomes a notification, an email, and a push message to any subscribed browser.', }, + { + name: 'credits', + description: + 'Credit codes and invites. A code grants credit in US cents; each account has three single-use invites and one open ?ref= link.', + }, { name: 'rankings', description: 'Which listings are read, applied to and paying most.' }, { name: 'auth', description: 'Device flow for terminals, magic links for browsers.' }, { name: 'federation', description: 'The directory of instances, and search across them.' }, @@ -154,7 +159,10 @@ export function openApiDocument(config: Config): Record { param('workplace', 'remote, hybrid, onsite'), param('seniority', 'intern, junior, mid, senior, staff, principal, lead'), param('agentPolicy', 'welcome, disclose, human-only'), - param('tag', 'Repeatable. Matches tags and stack.'), + param( + 'tag', + 'Repeatable, or comma-separated as tags=. Every requested tag must occur in tags or stack.', + ), param( 'salaryMin', 'Minimum annualised salary, compared against the top of each range.', @@ -268,8 +276,22 @@ export function openApiDocument(config: Config): Record { tags: ['apply'], summary: 'Applications to a job. Members of the employer only.', security: [{ bearer: [] }], - parameters: [pathParam('slug')], - responses: { 200: ok('The applications.'), 401: err(), 403: err() }, + parameters: [ + pathParam('slug'), + { + name: 'limit', + in: 'query', + schema: { type: 'integer', minimum: 1, maximum: 100, default: 100 }, + }, + { name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0, default: 0 } }, + ], + responses: { + 200: ok( + 'A page of applications, the total across all pages, and the page coordinates.', + ), + 401: err(), + 403: err(), + }, }, }, '/api/v1/applications/{id}/decision': { @@ -302,9 +324,11 @@ export function openApiDocument(config: Config): Record { post: { tags: ['apply'], summary: 'Send a draft application.', + description: + 'The draft is sent only while its listing is published and unexpired. If the listing has closed or expired, the draft stays saved and the response is 409.', security: [{ bearer: [] }], parameters: [pathParam('id')], - responses: { 200: ok('Sent.'), 401: err(), 404: err() }, + responses: { 200: ok('Sent.'), 401: err(), 404: err(), 409: err() }, }, }, '/api/v1/orgs': { @@ -390,6 +414,35 @@ export function openApiDocument(config: Config): Record { responses: { 200: ok('The caller.'), 401: err() }, }, }, + '/api/v1/me/profile': { + get: { + tags: ['auth'], + summary: + 'Your contact details: email, first_name, last_name, company_domain, job_title, linkedin_url. Private to you.', + security: [{ bearer: [] }], + responses: { 200: ok('The profile and which fields are still missing.'), 401: err() }, + }, + put: { + tags: ['auth'], + summary: 'Replace your contact details. email, first_name and last_name are required.', + security: [{ bearer: [] }], + responses: { 200: ok('The saved profile.'), 400: err(), 401: err() }, + }, + patch: { + tags: ['auth'], + summary: 'Change only the contact fields you send; null clears one.', + security: [{ bearer: [] }], + responses: { 200: ok('The saved profile.'), 400: err(), 401: err() }, + }, + }, + '/api/v1/me/jobs': { + get: { + tags: ['jobs'], + summary: 'Your organization listings, including drafts and closed jobs.', + security: [{ bearer: [] }], + responses: { 200: ok('items: Job[], total.'), 401: err() }, + }, + }, '/api/v1/candidates': { get: { tags: ['candidates'], @@ -634,6 +687,42 @@ export function openApiDocument(config: Config): Record { responses: { 200: ok('items: Agent[], total.'), 401: err() }, }, }, + '/api/v1/credits': { + get: { + tags: ['credits'], + summary: 'Your credit balance (US cents) and recent history.', + security: [{ bearer: [] }], + responses: { 200: ok('unit, balanceCents, balance, history[].'), 401: err() }, + }, + }, + '/api/v1/credits/redeem': { + post: { + tags: ['credits'], + summary: 'Redeem a credit code: {"code": "AJ-XXXX-XXXX-XXXX-XXXX"}.', + description: + 'Error codes: invalid, expired, used_up, already_redeemed, revoked. Case, spaces and dashes in the code do not matter.', + security: [{ bearer: [] }], + responses: { 200: ok('addedCents, balanceCents, balance.'), 400: err(), 401: err() }, + }, + }, + '/api/v1/invites': { + get: { + tags: ['credits'], + summary: 'Your invite quota, invites, affiliate code and ?ref= link.', + security: [{ bearer: [] }], + responses: { + 200: ok('quota, invites[], affiliateCode, affiliateUrl, bonusCents.'), + 401: err(), + }, + }, + post: { + tags: ['credits'], + summary: + 'Make an invite: {"note": "for Ada"}. The code and its /i/ link are returned once.', + security: [{ bearer: [] }], + responses: { 201: ok('code, url, quota.'), 401: err(), 409: err() }, + }, + }, '/api/v1/watches': { get: { tags: ['watches'], diff --git a/src/server/routes/pages.tsx b/src/server/routes/pages.tsx index aad84d2..a3fee41 100644 --- a/src/server/routes/pages.tsx +++ b/src/server/routes/pages.tsx @@ -8,8 +8,9 @@ */ import { Hono } from 'hono'; +import { PROFILE_ENHANCE_SCRIPT, validateProfile } from '@profullstack/profile'; import type { Context } from 'hono'; -import { deleteCookie, setCookie } from 'hono/cookie'; +import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; import { approveDeviceCode, consumeMagicLink, @@ -24,6 +25,8 @@ import { type Viewer, } from '../../core/auth.ts'; import { + applicationPage, + countApplications, createApplication, decideApplication, listApplications, @@ -63,12 +66,16 @@ import { parseQuery } from '../../schema/query.ts'; import { jobPostingJsonLd } from '../../schema/jsonld.ts'; import { toPlainText } from '../../markup/markdown.ts'; import { renderMarkdown } from '../../markup/markdown.ts'; -import { parseResume, resumeTemplate } from '../../markup/resume.ts'; +import { parseResume, resumeBodyMarkdown, resumeTemplate } from '../../markup/resume.ts'; import { openProfileFromResume } from '../../markup/openprofile.ts'; import { Layout, type PageProps } from '../../views/layout.tsx'; import { EmployerDetail, EmployerList, JobDetail, JobList } from '../../views/jobs.tsx'; -import { DevicePage, LoginPage } from '../../views/auth.tsx'; -import { MePage, ResumeEditor } from '../../views/me.tsx'; +import { DevicePage, LoginPage, SignupPage } from '../../views/auth.tsx'; +import { profileStore, saveCarriedProfile } from '../../core/profile.ts'; +import { MePage, ProfileCard, ResumeEditor } from '../../views/me.tsx'; +import { CreditsCard } from '../../views/credits.tsx'; +import { creditSummary, formatCents, REDEEM_MESSAGES } from '../../core/credits.ts'; +import { applySignupCredits, carried, pendingInvite } from '../signup-credits.ts'; import { ManageJobPage, NewEmployerPage, PostJobPage } from '../../views/post.tsx'; import { NetworkPage, NetworkSearchPage } from '../../views/network.tsx'; import { DocsPage, SpecPage } from '../../views/docs.tsx'; @@ -126,7 +133,7 @@ import { writerProvider, } from '../../core/agentwriter.ts'; import { readSpec } from './specs.ts'; -import { beginConnect, disconnect, getAccount, refreshWallets } from '../../core/coinpay.ts'; +import { beginConnect, disconnect, getAccount } from '../../core/coinpay.ts'; import { listInvoicesFor } from '../../core/invoices.ts'; import { BillingCard } from '../../views/inbox.tsx'; import { AgentsCard } from '../../views/agents.tsx'; @@ -360,18 +367,11 @@ export function pageRoutes(): Hono { resolved = saved?.markdown ?? null; } - const application = await createApplication(pool, job.id, validated.value); - if (resolved !== null || viewer !== null) { - await pool.query( - `update applications set resume_markdown = $2, resume_title = $3, user_id = $4 where id = $1`, - [ - application.id, - resolved, - resolved === null ? null : (parseResume(resolved).name ?? 'Resume'), - viewer?.id ?? null, - ], - ); - } + const application = await createApplication(pool, job.id, validated.value, { + userId: viewer?.id ?? null, + resumeMarkdown: resolved, + resumeTitle: resolved === null ? null : (parseResume(resolved).name ?? 'Resume'), + }); return c.html( @@ -490,7 +490,7 @@ export function pageRoutes(): Hono { { }); pages.post('/employers/:slug/updates', async (c) => { - const { pool } = c.get('deps'); + const { pool, config } = c.get('deps'); const viewer = requireViewer(c); if (viewer instanceof Response) return viewer; const org = await getOrgBySlug(pool, c.req.param('slug')); @@ -834,7 +834,7 @@ export function pageRoutes(): Hono { pool, viewer.id, { kind: 'employer', orgId: org.id }, - { body: form['body'], link: form['link'] }, + { body: form['body'], link: form['link'], board: config.publicUrl }, ); if (typeof posted === 'string') return employerPage(c, org.slug, posted); return c.redirect(`/employers/${org.slug}`, 303); @@ -847,7 +847,7 @@ export function pageRoutes(): Hono { * their page is whichever resume they listed. */ pages.post('/me/updates', async (c) => { - const { pool } = c.get('deps'); + const { pool, config } = c.get('deps'); const viewer = requireViewer(c); if (viewer instanceof Response) return viewer; @@ -863,7 +863,7 @@ export function pageRoutes(): Hono { pool, viewer.id, { kind: 'candidate', userId: viewer.id }, - { body: form['body'], link: form['link'] }, + { body: form['body'], link: form['link'], board: config.publicUrl }, ); if (typeof posted === 'string') return candidatePage(c, slug, posted); return c.redirect(`/candidates/${slug}`, 303); @@ -906,6 +906,74 @@ export function pageRoutes(): Hono { ); }); + pages.get('/signup', (c) => { + if (c.get('viewer') !== null) return c.redirect('/me', 302); + const next = safeRedirect(new URL(c.req.url).searchParams.get('next')); + const invite = pendingInvite(c); + return c.html( + + + , + ); + }); + + /** + * Signup is a magic link with the shared profile fields on it. They are + * checked before any link exists, and a refusal goes back to the form with + * what was typed - the same answer for a known or unknown address, so it says + * nothing about who has an account. The account itself is made when the + * link is spent, which is where the fields are saved. + */ + pages.post('/signup', async (c) => { + const { pool, config, mailer } = c.get('deps'); + const form = await formOf(c); + const next = safeRedirect(form['next']); + const check = validateProfile(form); + const email = normaliseEmail(form['email']); + if (!check.ok || email === null) { + const errors: Record = { ...check.errors }; + if (email === null && errors['email'] === undefined) { + errors['email'] = 'That does not look like an email address.'; + } + return c.html( + + + , + 400, + ); + } + + const link = await startMagicLink(pool, email, next, check.profile, carried(c, form)); + const url = `${config.publicUrl}/auth/callback?token=${link.token}`; + const delivered = await deliverMagicLink({ + mailer, + boardName: config.boardName, + email, + url, + redirect: next, + }); + void sweepExpired(pool).catch(() => undefined); + return c.html( + + + , + ); + }); + + // The signup form's company-from-work-email helper (@profullstack/profile), + // served as a file because the policy allows no inline script. + pages.get('/vendor-profile.js', (c) => + c.body(PROFILE_ENHANCE_SCRIPT, 200, { + 'content-type': 'text/javascript; charset=utf-8', + 'cache-control': 'public, max-age=3600', + }), + ); + pages.post('/login', async (c) => { const { pool, config, mailer } = c.get('deps'); const form = await formOf(c); @@ -920,7 +988,7 @@ export function pageRoutes(): Hono { } const next = safeRedirect(form['next']); - const link = await startMagicLink(pool, email, next); + const link = await startMagicLink(pool, email, next, null, carried(c, form)); const url = `${config.publicUrl}/auth/callback?token=${link.token}`; const delivered = await deliverMagicLink({ mailer, @@ -956,6 +1024,12 @@ export function pageRoutes(): Hono { ); } + // A signup's profile fields, saved now that the account exists. A failure + // is logged, never fatal: /me asks again. + await saveCarriedProfile(pool, consumed.user, consumed.profile); + // An invite or ?ref= that came with the sign-up, for a new account only. + await applySignupCredits(c, consumed); + const session = await createSession(pool, consumed.user.id, { label: 'browser' }); setSessionCookie(c, session); return c.redirect(consumed.redirect ?? '/me', 303); @@ -963,9 +1037,13 @@ export function pageRoutes(): Hono { pages.post('/logout', async (c) => { const { pool } = c.get('deps'); - const cookie = c.req.header('cookie') ?? ''; - const token = new RegExp(`${SESSION_COOKIE}=([^;]+)`).exec(cookie)?.[1]; - if (token !== undefined) await revokeSession(pool, decodeURIComponent(token)); + // The same read the middleware uses, so the two agree on what the cookie + // says. A cookie can carry anything, and decoding a malformed escape by + // hand threw out of decodeURIComponent as a 500; getCookie leaves what it + // cannot decode alone, and a token that matches no session deletes + // nothing. + const token = getCookie(c, SESSION_COOKIE); + if (token !== undefined) await revokeSession(pool, token); deleteCookie(c, SESSION_COOKIE, { path: '/' }); return c.redirect('/', 303); }); @@ -1056,6 +1134,11 @@ export function pageRoutes(): Hono { listAgents(pool, viewer.id), ]); const notice = new URL(c.req.url).searchParams.get('coinpay'); + const credit = await creditSummary(pool, viewer.id).catch(() => null); + const creditNotice = creditNoticeOf(new URL(c.req.url).searchParams); + const contact = await profileStore(pool) + .get(viewer.id) + .catch(() => null); return c.html( @@ -1077,6 +1160,16 @@ export function pageRoutes(): Hono { updateMax={BODY_MAX} recommendations={{ received, given }} > + + {credit !== null && ( + + )} { ); }); + /** The /me contact form; the API twin is PATCH /api/v1/me/profile. */ + pages.post('/me/profile', async (c) => { + const { pool } = c.get('deps'); + const viewer = requireViewer(c); + if (viewer instanceof Response) return viewer; + const form = await formOf(c); + const check = validateProfile({ ...form, email: form['email'] || viewer.email }); + if (!check.ok) { + return c.html( + + + , + 400, + ); + } + await profileStore(pool).save(viewer.id, check.profile); + return c.redirect('/me?profile=saved#profile', 303); + }); + /** Connect a CoinPay account: off to CoinPay, back to /api/v1/coinpay/callback. */ pages.get('/me/coinpay/connect', async (c) => { const { pool, coinpay, config } = c.get('deps'); @@ -1108,15 +1220,14 @@ export function pageRoutes(): Hono { return c.redirect(url, 302); }); - pages.post('/me/coinpay/refresh', async (c) => { - const { pool, coinpay } = c.get('deps'); + /** + * The board keeps no CoinPay token, so re-reading wallets is a fresh + * consent on CoinPay. Kept as a POST for old forms and bookmarks. + */ + pages.post('/me/coinpay/refresh', (c) => { const viewer = requireViewer(c); if (viewer instanceof Response) return viewer; - if (coinpay === null) return c.redirect('/me#billing', 303); - const result = await refreshWallets(pool, coinpay, viewer.id); - const notice = - typeof result === 'string' ? result : `Wallets refreshed: ${result.wallets.length}.`; - return c.redirect(`/me?coinpay=${encodeURIComponent(notice)}#billing`, 303); + return c.redirect('/me/coinpay/connect', 303); }); pages.post('/me/coinpay/disconnect', async (c) => { @@ -1407,7 +1518,10 @@ export function pageRoutes(): Hono { if (job === null) return c.notFound(); if (!(await isMember(pool, viewer.id, job.org.id))) return c.notFound(); - const applications = await listApplications(pool, job.id); + const { limit, offset: requestedOffset } = applicationPage(new URL(c.req.url).searchParams); + const applicationTotal = await countApplications(pool, job.id); + const offset = Math.min(requestedOffset, Math.max(0, applicationTotal - 1)); + const applications = await listApplications(pool, job.id, limit, offset); const detailed = await Promise.all( applications.map(async (application) => { const row = await pool.query<{ @@ -1434,6 +1548,9 @@ export function pageRoutes(): Hono { job={job} html={renderMarkdown(job.description, { headingOffset: 2 })} applications={detailed} + applicationTotal={applicationTotal} + applicationOffset={offset} + applicationLimit={limit} publicUrl={config.publicUrl} {...(error === undefined ? {} : { error })} /> @@ -1606,6 +1723,27 @@ export function pageRoutes(): Hono { return pages; } +function invitePropOf(invite: string | null): { invite?: string } { + return invite === null ? {} : { invite }; +} + +/** The redirect after POST /me/redeem says how it went: ?credit=added¢s=500 or ?credit=. */ +function creditNoticeOf( + params: URLSearchParams, +): { variant: 'success' | 'error'; text: string } | null { + const credit = params.get('credit'); + if (credit === null) return null; + if (credit === 'added') { + const cents = Number.parseInt(params.get('cents') ?? '0', 10) || 0; + return { variant: 'success', text: `Code redeemed: ${formatCents(cents)} added.` }; + } + const reason = credit as keyof typeof REDEEM_MESSAGES; + return { + variant: 'error', + text: REDEEM_MESSAGES[reason] ?? 'That is not a code. Check it and try again.', + }; +} + function setSessionCookie(c: Ctx, token: string): void { const { config } = c.get('deps'); setCookie(c, SESSION_COOKIE, token, { diff --git a/src/server/serve.ts b/src/server/serve.ts index d6a5091..cdc53a5 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -9,6 +9,7 @@ import { loadConfig, sameOrigin, type Config } from '../config.ts'; import { getPool } from '../db/pool.ts'; import { migrate } from '../db/migrate.ts'; import { startAnnouncing, type Announcer } from '../directory/announce.ts'; +import { createCoinPay, sweepLegacyTokens } from '../core/coinpay.ts'; import { sweep } from '../directory/registry.ts'; import { createApp } from './app.tsx'; @@ -73,6 +74,33 @@ export async function startServer(overrides: Partial = {}): Promise { + void sweepLegacyTokens(pool, coinpay, { legacy }) + .then((r) => { + if (r.migrated + r.refused + r.dropped + r.deferred > 0 || r.remaining > 0) { + console.log( + `coinpay tokens: ${r.migrated} migrated, ${r.refused} refused, ${r.dropped} dropped, ${r.deferred} deferred, ${r.remaining} remaining`, + ); + } + if (r.remaining === 0 && tokenSweeper !== null) { + clearInterval(tokenSweeper); + tokenSweeper = null; + } + }) + .catch((error: unknown) => console.error('coinpay token sweep failed', error)); + }; + tokenSweeper = setInterval(runTokens, 6 * 60 * 60 * 1000); + tokenSweeper.unref?.(); + setTimeout(runTokens, 15_000).unref?.(); + } + let sweeper: NodeJS.Timeout | null = null; if (config.isDirectory) { console.log(' directory on'); @@ -99,6 +127,7 @@ export async function startServer(overrides: Partial = {}): Promise { announcer?.stop(); if (sweeper !== null) clearInterval(sweeper); + if (tokenSweeper !== null) clearInterval(tokenSweeper); await new Promise((resolve) => server.close(() => resolve())); }, }; diff --git a/src/server/signup-credits.ts b/src/server/signup-credits.ts new file mode 100644 index 0000000..0a7630d --- /dev/null +++ b/src/server/signup-credits.ts @@ -0,0 +1,106 @@ +/** + * What a sign-up carries besides the address: an invite from /i/ and an + * affiliate code from ?ref=. Both are read from the form or the cookie when the + * link is asked for, saved on the magic link, and applied when it is spent, so + * a link opened in a different browser (a phone's mail app) still counts. + */ + +import type { Context, MiddlewareHandler } from 'hono'; +import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; +import type { AppEnv } from './deps.ts'; +import type { ConsumedLink } from '../core/auth.ts'; +import { + acceptInvite, + attributeRef, + canonicalInvite, + cleanRef, + INVITE_COOKIE, + REF_COOKIE, +} from '../core/credits.ts'; + +type Ctx = Context; + +const THIRTY_DAYS = 30 * 24 * 60 * 60; + +function secure(c: Ctx): boolean { + return c.get('deps').config.publicUrl.startsWith('https://'); +} + +export function rememberInvite(c: Ctx, code: string): void { + setCookie(c, INVITE_COOKIE, code, { + path: '/', + httpOnly: true, + sameSite: 'Lax', + secure: secure(c), + maxAge: THIRTY_DAYS, + }); +} + +/** The invite and ref a sign-up form should carry on its link. */ +export function carried( + c: Ctx, + form: Record = {}, +): { invite: string | null; ref: string | null } { + return { + invite: canonicalInvite(form['invite'] ?? getCookie(c, INVITE_COOKIE)), + ref: cleanRef(form['ref'] ?? getCookie(c, REF_COOKIE)), + }; +} + +/** The invite to show on /signup, from ?invite= or the cookie. */ +export function pendingInvite(c: Ctx): string | null { + return canonicalInvite( + new URL(c.req.url).searchParams.get('invite') ?? getCookie(c, INVITE_COOKIE), + ); +} + +/** + * First touch on ?ref=: the first affiliate code a browser arrives with is + * kept for 30 days and never overwritten. The cookie is @profullstack/ + * referrals' `referral_code`, so a site that adds that package later does not + * count one sign-up twice. + */ +export function trackRef(): MiddlewareHandler { + return async (c, next) => { + if (c.req.method === 'GET') { + const ref = cleanRef(new URL(c.req.url).searchParams.get('ref')); + if (ref !== null && getCookie(c, REF_COOKIE) === undefined) { + setCookie(c, REF_COOKIE, ref, { + path: '/', + httpOnly: true, + sameSite: 'Lax', + secure: secure(c), + maxAge: THIRTY_DAYS, + }); + } + } + await next(); + }; +} + +/** + * Apply an invite and a ref to an account the magic link just created. Only + * a new account: an existing member following an invite link spends nothing + * and earns nothing. Failures are logged and never stop the sign-in. + */ +export async function applySignupCredits(c: Ctx, consumed: ConsumedLink): Promise { + const { pool, config } = c.get('deps'); + const invite = consumed.invite ?? canonicalInvite(getCookie(c, INVITE_COOKIE)); + const ref = consumed.ref ?? cleanRef(getCookie(c, REF_COOKIE)); + if (invite !== null) deleteCookie(c, INVITE_COOKIE, { path: '/' }); + if (consumed.user.created !== true) return; + if (invite !== null) { + try { + await acceptInvite(pool, config, invite, consumed.user); + } catch (error) { + console.error('invite accept failed', error instanceof Error ? error.message : error); + } + } + if (ref !== null) { + try { + await attributeRef(pool, ref, consumed.user.id); + } catch (error) { + console.error('ref attribution failed', error instanceof Error ? error.message : error); + } + } +} diff --git a/src/tui/index.ts b/src/tui/index.ts index a41792d..ea96cb8 100644 --- a/src/tui/index.ts +++ b/src/tui/index.ts @@ -84,17 +84,9 @@ export async function startTui(client: BoardClient): Promise { const loadListings = async (): Promise => { if (!state.canPost) return; - const me = await busy('loading listings', () => client.me()); - if (me === null) return; - const slugs = new Set(me.orgs.map((org) => org.slug)); - const pages = await Promise.all( - me.orgs.map((org) => client.search({ org: org.slug, limit: 50 }).catch(() => null)), - ); - const listings = pages - .filter((page): page is NonNullable => page !== null) - .flatMap((page) => page.items) - .filter((job: Job) => slugs.has(job.org.slug)); - invalidate({ ...state, listings, listingIndex: 0 }); + const result = await busy('loading listings', () => client.myJobs()); + if (result === null) return; + invalidate({ ...state, listings: result.items, listingIndex: 0 }); }; const loadInbox = async (): Promise => { @@ -298,7 +290,14 @@ export async function startTui(client: BoardClient): Promise { return; } if (key === '/') { - invalidate({ ...state, editing: true, tab: 'find', detail: null, applications: null, query: '' }); + invalidate({ + ...state, + editing: true, + tab: 'find', + detail: null, + applications: null, + query: '', + }); return; } if (key === 'tab') { diff --git a/src/views/ad-unit.tsx b/src/views/ad-unit.tsx new file mode 100644 index 0000000..cc9c666 --- /dev/null +++ b/src/views/ad-unit.tsx @@ -0,0 +1,70 @@ +/** + * One CrawlProof ad unit, as a script-free cross-origin iframe. + * + * `ad.js` fills a placeholder with a `srcdoc` iframe, and a srcdoc document + * inherits this page's Content-Security-Policy: the creative's inline styles + * and remote images would only render with a far looser policy than this + * board sends. `/api/ads/frame` serves the same creative as a real + * cross-origin document, so the cost here is one `frame-src` entry, no + * third-party JavaScript and nothing written to this origin's storage. + * Impressions are metered server-side exactly as on the script path. + * + * The format is `text_link`, the one fluid creative: 40px tall at any width, + * with its own "Sponsored" mark. Banners are laid out at a fixed pixel width + * and would be cropped in a narrow column, and choosing by viewport needs a + * script. No `&theme=`: the frame answers prefers-color-scheme itself. + */ +import type { FC } from 'hono/jsx'; + +export const AD_ORIGIN = 'https://crawlproof.com'; +export const AD_SLOT = '9c3d6e3d-ae1f-43ff-b6d9-4f42f11725ce'; + +/** First path segments that never carry an ad: flows, accounts, dashboards, admin. */ +const NO_AD_SEGMENTS = new Set([ + 'tracker', + 'fleets', + 'post', + 'post-a-job', + 'me', + 'inbox', + 'notifications', + 'applications', + 'admin', + 'login', + 'auth', + 'device', + 'invoices', + 'coinpay', + 'revisions', +]); + +/** + * Public, indexable pages only. A noindex page is a dashboard or a one-off flow, + * and the homepage carries no ad. + */ +export function showsAd(path: string, noindex?: boolean): boolean { + if (noindex) return false; + const first = path.split(/[/?#]/)[1] ?? ''; + if (first === '') return false; + return !NO_AD_SEGMENTS.has(first); +} + +export const AdUnit: FC<{ format?: 'text_link' }> = ({ format = 'text_link' }) => { + const src = `${AD_ORIGIN}/api/ads/frame?slot=${encodeURIComponent(AD_SLOT)}&format=${encodeURIComponent(format)}`; + return ( + + ); +}; diff --git a/src/views/auth.tsx b/src/views/auth.tsx index ac07c68..12a1700 100644 --- a/src/views/auth.tsx +++ b/src/views/auth.tsx @@ -6,9 +6,18 @@ * browser has WebAuthn at all. */ +import { COLUMNS as PROFILE_COLUMNS, renderProfileFields } from '@profullstack/profile'; +import { raw } from 'hono/html'; import type { FC } from 'hono/jsx'; import { Alert, Card, Field } from './layout.tsx'; +/** + * The rest of the shared profile (@profullstack/profile), asked for on /signup + * only. Email is the form's own box, so the package renders the other five. A + * plain sign-in never sees or needs them. + */ +export const SIGNUP_FIELDS = PROFILE_COLUMNS.filter((field) => field !== 'email'); + export const LoginPage: FC<{ sent?: string; error?: string; next?: string; unsent?: boolean }> = ({ sent, error, @@ -50,6 +59,9 @@ export const LoginPage: FC<{ sent?: string; error?: string; next?: string; unsen +

+ New here? Create an account. +