adya-ras-connect
v0.2.3
Published
Mount the RAS integration surface into your Express app: SSO handoff, record sync, and deliverable return.
Maintainers
Readme
adya-ras-connect
Mount the RAS integration surface into your Express app. Three functions of your code; the protocol is ours.
npm install adya-ras-connectimport express from 'express'
import { rasConnect, paginateByWatermark } from 'adya-ras-connect'
const app = express()
app.use(express.json({ limit: '32mb' })) // deliverables arrive as base64
app.use('/ras', rasConnect({
integrationKey: process.env.RAS_INTEGRATION_KEY!,
// Who is signed in? Your auth, your call. Return null to refuse.
resolveUser: async (req) => {
const user = await currentUser(req)
if (!user?.canUseRas) return null
return {
user_id: String(user.id),
email: user.email,
name: user.name,
company_id: user.orgId ?? null,
}
},
// A page of your records for RAS to index and analyse.
// `>=` and the helper are both load-bearing — see Pagination below.
listRecords: async ({ userId, since, limit }) => {
const rows = await db.records.find({
owner: userId,
...(since ? { updated_at: { $gte: new Date(since) } } : {}),
}).sort({ updated_at: 1 }).limit(limit * 2)
return paginateByWatermark(rows, {
limit,
updatedAt: (r) => r.updated_at.toISOString(),
})
},
// A finished report coming back. Already decoded and hash-verified.
onDeliverable: async (d) => {
await storage.put(`ras/${d.deliverable_id}`, d.content)
await db.deliverables.insert({
id: d.deliverable_id, user: d.user_id, record: d.record_id,
title: d.title, verdict: d.review_verdict,
})
},
}))That's the integration.
What you get
| route | who calls it | auth |
|---|---|---|
| POST /handoff/code | your frontend | your session — never the integration key |
| POST /handoff/redeem | RAS | x-integration-key |
| GET /records | RAS | x-integration-key |
| POST /deliverables | RAS | x-integration-key |
The two auth paths are disjoint on purpose. /handoff/code sits behind your own
login because a real person is present. The other three carry no user at all —
they are background jobs — so they authenticate as a machine.
Direction is fixed: RAS always calls you. You never call RAS, and you need no outbound credential. You hold one shared secret and that is the whole surface.
The SDK handles: 32-byte code entropy, two-minute expiry, single-use consumption, hashed-at-rest code storage, timing-safe key comparison, payload validation and bounds, base64 decoding, SHA-256 content verification, pagination envelopes, and the error shapes RAS expects.
The handoff, end to end
- Your frontend asks for a code —
POST /ras/handoff/code, carrying your session. - You get
{ data: { code, expires_in_ms } }. - Redirect the browser to RAS with it:
https://ras.example.com/handoff?code=... - RAS redeems it against you server-to-server and gets the claims.
- RAS provisions a workspace and signs the scientist in.
The user's password never leaves your system. RAS never sees a credential of yours beyond the shared key, and re-checks entitlement with you at redeem time — so access revoked between the click and the redeem is honoured.
Pass anything you like as run_context when minting; RAS hands it back to the
workspace untouched, which is how "open RAS on this record" works.
Options
| option | required | notes |
|---|---|---|
| integrationKey | ✅ | Must match what RAS holds for you. Min 16 chars; validated at construction, not at first request. |
| resolveUser | ✅ | Return null for "not signed in" and "not entitled" — they must be indistinguishable to the caller. |
| listRecords | ✅ | Sort ascending by your update timestamp and honour limit. |
| onDeliverable | | Omit and /deliverables returns 501. |
| codeStore | | Defaults to in-process. See below. |
| codeTtlMs | | Default 120000. |
| logger | | { warn, error }. Defaults to silence. |
Running more than one instance
The default code store is an in-process Map. A code minted on one instance
cannot be redeemed on another, so behind a load balancer the handoff fails for a
fraction of users — intermittently, which reads as a bug rather than a
misconfiguration.
If you run more than one process, supply a shared store:
codeStore: {
async put(hash, entry) {
await redis.set(`ras:${hash}`, JSON.stringify(entry), 'PX', 120_000)
},
async take(hash) {
// MUST be atomic. GETDEL, not GET then DEL.
const raw = await redis.getdel(`ras:${hash}`)
return raw ? JSON.parse(raw) : null
},
}take being atomic is what makes a code single-use. A read-then-delete
implementation lets two concurrent redemptions both succeed, which turns a leaked
code into a working one.
Pagination — read this one
This is the only part of the integration that is easy to get wrong, and the failure is silent: records simply never arrive, and both sides report success. A client that hand-rolled it in testing lost 25% of its data before anyone noticed.
Use the helper and you cannot hit it:
import { rasConnect, paginateByWatermark } from 'adya-ras-connect'
listRecords: async ({ userId, since, limit }) => {
const rows = await db.shipments.find({
owner: userId,
...(since ? { updated_at: { $gte: new Date(since) } } : {}), // >= not >
}).sort({ updated_at: 1 }).limit(limit * 2) // over-fetch
return paginateByWatermark(rows, {
limit,
updatedAt: (r) => r.updated_at.toISOString(),
})
}Why, if you would rather write it yourself
watermark is the last-modified time of the newest record you hand over. RAS
stores it and sends it back as since.
Records that share a timestamp are the problem — anything written in the same tick, by a batch job, an overnight import, or a bulk edit. Two rules:
1. Filter with >=, never >. With a strict >, every record sharing the
last timestamp in a page is skipped for ever: the next request asks for rows
strictly newer than a timestamp they are equal to. Re-offering boundary records
costs nothing — RAS upserts by id and dedups documents on content hash, so a
record it has already seen is a no-op.
2. Take the watermark from the last row you RETURN, not the newest row you
fetched. Truncate to limit first, then read the timestamp off the final row
of the truncated page. Anything you trimmed is strictly newer, so it comes back
on the next page.
Rule 2 bites hardest when you merge sources. Query two tables, cap each at
limit, and they reach different points in time; take the maximum across both
and the watermark jumps past unread rows in the slower one, which then fall
below since permanently.
Also
- Return a real watermark whenever the page is non-empty.
nullmakes RAS reuse the previous one and re-read the same window indefinitely. - Honour
limit. Returning more than asked is not harmful, but returning2 × limitbecause you capped two queries separately is a symptom of rule 2. - Sort ascending by your timestamp. An unsorted page truncated to
limitdrops arbitrary records rather than the newest.
Response shapes
Success is { "data": ... }; failure is { "error": { "code", "message" } }.
| code | status | meaning |
|---|---|---|
| unauthorized | 401 | integration key missing or wrong |
| forbidden | 403 | resolveUser returned null |
| invalid_request | 400 | payload failed validation |
| code_invalid | 400 | code absent, expired, or already used |
| hash_mismatch | 400 | decoded bytes don't match content_hash |
| not_implemented | 501 | no onDeliverable configured |
Note on /runs
GET /runs is served as an alias of /records for RAS deployments predating the
rename. New integrations should ignore it; it will be removed once no registered
platform depends on it.
