DAILY NEWS

Stay Ahead, Stay Informed – Every Day

Advertisement
Integration with Workable public jobs API



Workable is an ATS with a public careers layer you can read without authentication. The widget-style JSON endpoint powers Workable-hosted career sites and embeddable job widgets.

This post shows how to list published jobs for one Workable account, request full descriptions, and normalize location and remote fields. For other ATS public feeds, see the Ashby, Greenhouse, and Lever posts.

Workable’s authenticated REST API v3 (https://{subdomain}.workable.com/spi/v3/) requires a bearer token and is meant for HR integrations, not public job aggregation.

Prerequisites

Node.js version 26
A company’s Workable account slug (see below)
No API key required for the public widget endpoint

Find the account slug

Workable career pages use URLs like https://apply.workable.com/{account_slug}/ or legacy https://{account_slug}.workable.com/. The slug is the account identifier in API paths.

Examples: huggingface for Hugging Face, flosum for Flosum.

API overview

Item
Value

Jobs (with details)
GET https://www.workable.com/api/accounts/{slug}?details=true

Locations
GET …/accounts/{slug}/locations

Departments
GET …/accounts/{slug}/departments

Auth
None

Format
JSON

Node fetch may follow a redirect to https://apply.workable.com/api/v1/widget/accounts/{slug}?details=true; both URLs return the same payload.

Set details=true to include description and full_description on each job. Without it you get summary fields only.

Common fields on each job in jobs():

Field
Description

title
Job title

url, shortlink

Apply links

location, locations

Structured location objects

experience
Seniority label (for example Mid-Senior level)

published_on, created_at

Timestamps

state
On public feeds this is often a region name, not listing status – prefer published_on to detect live roles

Basic integration

const accountSlug = process.env.WORKABLE_ACCOUNT_SLUG ?? ‘huggingface’;
const url = new URL(
`https://www.workable.com/api/accounts/${encodeURIComponent(accountSlug)}`,
);
url.searchParams.set(‘details’, ‘true’);

const response = await fetch(url);

if (!response.ok) {
throw new Error(`Workable API ${response.status}: ${response.statusText}`);
}

const data = await response.json();

for (const job of data.jobs ?? ()) {
console.log(job.title, ‘-‘, job.location?.location_str, ‘-‘, job.url);
}

Enter fullscreen mode

Exit fullscreen mode

Keep only published listings:

function isPublished(job) {
if (job.published_on?.trim()) return true;
return !job.state || job.state === ‘published’;
}

const publicJobs = (data.jobs ?? ()).filter(
(job) => job.title && (job.url || job.shortlink) && isPublished(job),
);

Enter fullscreen mode

Exit fullscreen mode

Locations and remote detection

Prefer location.location_str when present. Otherwise build a label from structured fields:

function stringifyLocation(entry) {
const city = entry.city?.trim();
const region = entry.state_code?.trim() || entry.region?.trim();
const country = entry.country_name?.trim() || entry.country?.trim();

if (city && region && country) return `${city}, ${region}, ${country}`;
if (city && country) return `${city}, ${country}`;
return country || city || ”;
}

function resolveLocation(job) {
const primary = job.location?.location_str?.trim();
if (primary) return primary;

const parts = (job.locations ?? ())
.map(stringifyLocation)
.filter(Boolean);

return parts.join(‘ / ‘) || ‘Unknown’;
}

function isRemoteJob(job, locationLabel) {
const structured =
Boolean(job.location?.telecommuting) ||
job.location?.workplace_type?.toLowerCase() === ‘remote’ ||
(job.locations ?? ()).some(
(loc) =>
Boolean(loc.telecommuting) ||
loc.workplace_type?.toLowerCase() === ‘remote’,
);

return structured || /remote/i.test(locationLabel);
}

Enter fullscreen mode

Exit fullscreen mode

Normalize to a stable shape

function normalizeWorkableJob(job, companyName) {
const location = resolveLocation(job);
const description = `${job.description ?? ”} ${job.full_description ?? ”}`.trim();

return {
id: job.id ?? job.shortcode,
title: job.title.trim(),
company: companyName,
location,
isRemote: isRemoteJob(job, location),
url: job.url || job.shortlink,
postedAt: job.created_at
? new Date(job.created_at)
: job.published_on
? new Date(job.published_on)
: null,
experience: job.experience ?? null,
description,
};
}

Enter fullscreen mode

Exit fullscreen mode

Optional companion calls enrich filters:

const (locationsRes, departmentsRes) = await Promise.all((
fetch(`https://www.workable.com/api/accounts/${accountSlug}/locations`),
fetch(`https://www.workable.com/api/accounts/${accountSlug}/departments`),
));

const { locations } = await locationsRes.json();
const { departments } = await departmentsRes.json();

Enter fullscreen mode

Exit fullscreen mode

Need help with your project?

Get personalized advice on your architecture, code, or career in a 45-minute 1-on-1 consultation.

→ Book a consultation



Source link

Part 2 Building an Authentication System from Scratch – Backend Setup


User Registration & Secure Password Hashing with bcrypt

In the previous article, we built the backend foundation by setting up Express.js, PostgreSQL, environment variables, and a clean layered architecture.

With the backend ready, it’s time to implement the first authentication feature—User Registration.

Although registration appears straightforward, it involves much more than simply storing user details in a database. A secure registration system must validate user input, prevent duplicate accounts, protect passwords, and ensure that sensitive information is never exposed.

In this article, we’ll build the complete registration workflow while following security best practices.

The registration process follows a layered architecture, where each layer has a single responsibility.

Client


Routes


Controller


Service


Repository


PostgreSQL

Enter fullscreen mode

Exit fullscreen mode

The overall workflow is:

The client submits the registration form.
The controller receives the request.
The service validates the data.
The repository checks whether the email already exists.
The password is securely hashed using bcrypt.
The user is stored in PostgreSQL.
A success response is returned to the client.

Instead of placing all the registration logic inside the controller, I divided the implementation into three layers.

Controller

Responsible only for:

Receiving the HTTP request
Calling the service layer
Returning the HTTP response

The controller should never contain business logic or database queries.

Service

The service contains the application’s business logic.

For registration, it is responsible for:

Validating the request
Checking whether the email already exists
Hashing the password
Calling the repository to save the user

This layer acts as the brain of the application.

Repository

The repository communicates directly with PostgreSQL.

Its responsibilities include:

Checking if a user already exists
Creating a new user
Executing SQL queries

Keeping SQL isolated inside repositories makes the application easier to maintain and test.

The controller receives the registration request and forwards the data to the service layer.

// Register Controller Screenshot Here

Enter fullscreen mode

Exit fullscreen mode

The controller itself performs very little work.

Its responsibility is simply to:

Extract the request body
Call the service
Return either a success or an error response

This keeps controllers lightweight and easy to understand.

The service contains the actual registration workflow.

// Register Service Screenshot Here

Enter fullscreen mode

Exit fullscreen mode

The registration service performs the following steps:

Check whether the email already exists.
Generate a secure password hash.
Create the user in PostgreSQL.
Return the newly created user.

Because all business rules live inside the service layer, future changes become much easier.

For example, adding email verification later would require changes only inside the service, without affecting controllers or repositories.

The repository is responsible only for database communication.

// Repository Screenshot Here

Enter fullscreen mode

Exit fullscreen mode

Typical repository functions include:

findByEmail()
createUser()

Keeping SQL queries isolated improves readability and keeps the service layer database-agnostic.

One of the biggest mistakes an application can make is storing passwords in plain text.

Imagine a database leak.

If passwords are stored as plain text, every user’s credentials become immediately visible.

Instead, passwords should always be transformed into a secure one-way hash before being stored.

This is exactly why we use bcrypt.

bcrypt is one of the most trusted password hashing libraries available for Node.js.

Unlike encryption, hashing is a one-way operation.

This means:

The original password cannot be recovered.
Even the application itself cannot view the user’s password.
Only password verification is possible.

When a user registers, bcrypt performs several operations internally.

Password


Generate Random Salt


Password + Salt


Multiple Hashing Rounds


Store Hash in Database

Enter fullscreen mode

Exit fullscreen mode

Each password receives its own randomly generated salt before hashing.

Because of this:

Two users with the same password will have completely different hashes.
Rainbow table attacks become ineffective.
Brute-force attacks become significantly slower due to bcrypt’s configurable cost factor.

During login, the user enters their password as plain text.

bcrypt then:

Reads the stored hash.
Extracts the embedded salt.
Hashes the entered password using the same salt.
Compares the generated hash with the stored hash.

If both hashes match, the user is successfully authenticated.

const isMatch = await bcrypt.compare(
enteredPassword,
storedHash
);

Enter fullscreen mode

Exit fullscreen mode

One of bcrypt’s biggest advantages is that developers never need to manually manage salts or compare hashes—the library handles the entire verification process securely.

Using bcrypt provides several important security advantages.

✅ Passwords are never stored in plain text.

✅ Every password uses a unique random salt.

✅ Identical passwords generate different hashes.

✅ Brute-force attacks become significantly slower.

✅ Rainbow table attacks are mitigated.

These features make bcrypt one of the industry standards for password protection.

Once the backend implementation was complete, I verified the registration API using Postman.

Request

POST /api/auth/register

Enter fullscreen mode

Exit fullscreen mode

{
“username”: “Sriya”,
“email”: “sriya@gmail.com”,
“password”: “Password123”
}

Enter fullscreen mode

Exit fullscreen mode

Response

{
“success”: true,
“user”: {
“id”: 1,
“username”: “Sriya”,
“email”: “sriya@gmail.com”
}
}

Enter fullscreen mode

Exit fullscreen mode

Notice that the response never includes the password or its hash.

Only non-sensitive user information is returned to the client.

Now that users can securely register and their passwords are safely stored, the next step is allowing them to authenticate.

In the next article, we’ll build the Login Flow, where we’ll:

Verify user credentials
Compare passwords using bcrypt
Generate JWT Access Tokens
Generate Refresh Tokens
Understand how JWT authentication works internally



Source link

CI is the wrong place to first hear about your npm dependencies



Your CI catches the npm vulnerability. Your developer is already three branches away and one standup behind. The package is installed, the lockfile regenerated, the import wired into a service, and the human who made that decision did it on a Tuesday afternoon with a tab open to Stack Overflow. Now the scanner is yelling.

From the terminal, that is not security. That is grief counseling.

That is the frame Sonu Kapoor lays out in a DevOps.com essay this week, and the engineering bones of it are correct.

A scanner is not a gate. It is a status check.

Kapoor’s argument is about feedback loops. A developer installs, codes, commits, pushes. Only then does CI run. By the time the finding surfaces, the decision to add the package, and the context for why, has evaporated. So has the lockfile churn that caused it. What started as “is this package safe?” becomes “fix this in a different sprint.” The scanner did its job. The fix is now a project.

He backs it with a small case study from the NestJS repo: a scan of package-lock.json returned 1,626 resolved packages and 25 vulnerabilities. Of those, 12 were directly fixable. Thirteen were transitive, buried in upstream graphs, waiting on someone else’s release. In a pipeline-first workflow, every dependency hop is a separate commit and a separate run. (Multiply by the number of services your team owns. Then by your runner-minutes budget. Send me the bill.)

The arithmetic gets ugly quickly. A single lockfile with more than fifteen hundred resolved packages is not exotic for a working Node app, it is the default. The chance that the first time anyone looks at that graph is during a pipeline run, after the merge intent is already in the reviewer’s queue, is the structural bug.

Where the essay is right, and where it gets too tidy

Concede the obvious. CI is not the problem. CI is fine. It runs uniformly, it cannot be skipped, and it is the right place to fail a build when an OSV record drops mid-week against a dependency that was clean at merge time. Validation is its native job.

The bug is treating CI as the first place anyone hears about an issue. Discovery there is a category error. You are using the post-flight checklist to taxi the plane.

So far, so unobjectionable. Where the essay gets convenient is the pitch. The cure on offer is a local CLI that Kapoor’s team ships, run against the lockfile before commit, splitting direct from transitive findings, OSV-backed. Useful, probably worth a try. Also: a tool. Tools come and go. A team can adopt this CLI today, lose interest in eight months, and the dependency problem will still be sitting in package-lock.json waiting for the next refactor.

A laptop CLI is the developer-ergonomics half of the answer. It is the half a vendor blog post is built to sell.

What actually moves the gate left

If CI is too late, the answer is not “tell the developer faster on their machine” and walk away. Laptops drift. Branches drift. The version of the scanner that ran on the senior engineer’s machine is not the version that ran on the contractor’s. A discovery step you can opt out of by closing a terminal is not a control. It is a courtesy.

What works, roughly in order of how cheap it is to set up:

A pre-commit hook that fails on a lockfile diff containing a known-bad package. Cheap, local, opt-in, fine as a first line.
A required PR check that runs the same scan against the lockfile before a reviewer is paged. Mandatory, visible inside the diff, blocking the merge instead of decorating the build.
A policy at the branch-protection layer that constrains what is allowed in the lockfile at all: license, registry source, signature, provenance. Unsigned additions to package-lock.json should not be a finding. They should be a closed PR.

Notice CI is in all three. It just stops being where you first find out.

The piece’s case study quietly makes this point against itself. When the only fix for a transitive finding is a chain of iterative upgrades, the question is not “can a developer iterate faster on their laptop?” Yes, obviously. The question is “why was a dependency with thirteen unresolved transitive issues allowed into the merge queue in the first place?”

Verdict

Kapoor is right about the asymmetry. CI is great for validation and structurally bad for discovery on a Node tree where the majority of findings can be someone else’s release schedule. If your security story today is “the pipeline will tell us,” you are not gated. You are notified.

Fix the notification step. Then fix the part where you were ever relying on it.



Source link