feat(napta): client-credentials auth + real project listing

Napta uses Auth0 M2M, not a static token: exchange client_id/secret for a cached
JWT, then list non-archived projects via JSON:API (include client name). Falls back
to fixtures when NAPTA_CLIENT_ID/SECRET are unset. Member resolution still stubbed.
This commit is contained in:
Julien Calixte
2026-06-26 16:16:06 +01:00
parent fc039ac0fc
commit 9ded10f68e
5 changed files with 187 additions and 28 deletions

View File

@@ -3,14 +3,19 @@
PORT=8000 PORT=8000
# Napta API — required for live data. Without it, the API serves demo fixtures. # Napta API (https://app.napta.io). Auth is Auth0 Machine-to-Machine: the backend
# Base URL + token: see https://app.napta.io (Settings → API). # exchanges client_id + client_secret for a JWT. Without BOTH, the API serves demo
NAPTA_API_TOKEN= # fixtures. Get an M2M credential from Napta (Settings -> API / your Napta admin).
NAPTA_CLIENT_ID=
NAPTA_CLIENT_SECRET=
NAPTA_BASE_URL=https://app.napta.io/api/v1 NAPTA_BASE_URL=https://app.napta.io/api/v1
# Override only if Napta tells you to:
# NAPTA_AUTH_URL=https://auth.napta.io/oauth/token
# NAPTA_AUDIENCE=backend
# Slack bot token (xoxb-...) with scopes: users:read, users:read.email # Slack bot token (xoxb-...) with scopes: users:read, users:read.email
# Used to resolve each person's profile picture by email. # Optional — without it, Members show initials Avatars instead of Slack photos.
SLACK_BOT_TOKEN= SLACK_BOT_TOKEN=
# SQLite location (avatar cache). Matches the Coolify persistent volume. # SQLite location (Slack directory cache). Matches the Coolify persistent volume.
DB_PATH=data/app.db DB_PATH=data/app.db

View File

@@ -1,19 +1,29 @@
export interface Config { export interface Config {
port: number port: number
naptaApiToken: string | null // Napta uses Auth0 Machine-to-Machine (client-credentials), not a static token:
// client_id + client_secret are exchanged for a short-lived JWT (see napta.ts).
naptaClientId: string | null
naptaClientSecret: string | null
naptaBaseUrl: string naptaBaseUrl: string
naptaAuthUrl: string
naptaAudience: string
slackBotToken: string | null slackBotToken: string | null
} }
export const config: Config = { export const config: Config = {
port: Number(process.env.PORT ?? 8000), port: Number(process.env.PORT ?? 8000),
naptaApiToken: process.env.NAPTA_API_TOKEN || null, naptaClientId: process.env.NAPTA_CLIENT_ID || null,
naptaClientSecret: process.env.NAPTA_CLIENT_SECRET || null,
naptaBaseUrl: process.env.NAPTA_BASE_URL || "https://app.napta.io/api/v1", naptaBaseUrl: process.env.NAPTA_BASE_URL || "https://app.napta.io/api/v1",
naptaAuthUrl: process.env.NAPTA_AUTH_URL || "https://auth.napta.io/oauth/token",
naptaAudience: process.env.NAPTA_AUDIENCE || "backend",
slackBotToken: process.env.SLACK_BOT_TOKEN || null, slackBotToken: process.env.SLACK_BOT_TOKEN || null,
} }
// Live data needs BOTH a Napta token (to list projects/people) and a Slack // Listing Projects and Members needs Napta. Without it, the API serves fixtures.
// token (to resolve avatars). Without both, the API serves demo fixtures. export const hasNaptaCredentials: boolean = Boolean(
export const hasLiveCredentials: boolean = Boolean( config.naptaClientId && config.naptaClientSecret,
config.naptaApiToken && config.slackBotToken,
) )
// Slack only resolves Avatars; it's optional and degrades to initials when absent.
export const hasSlackCredentials: boolean = Boolean(config.slackBotToken)

View File

@@ -1,35 +1,49 @@
import { serve } from "@hono/node-server" import { serve } from "@hono/node-server"
import { Hono } from "hono" import { Hono } from "hono"
import { logger } from "hono/logger" import { logger } from "hono/logger"
import { config, hasLiveCredentials } from "./config.ts" import { config, hasNaptaCredentials } from "./config.ts"
import { fixtureMembers, fixtureProjects } from "./fixtures.ts" import { fixtureMembers, fixtureProjects } from "./fixtures.ts"
import { fetchNaptaProjectMembers, fetchNaptaProjects } from "./napta.ts" import { fetchNaptaProjectMembers, fetchNaptaProjects } from "./napta.ts"
import { enrichWithSlackAvatars } from "./slack.ts" import { enrichWithSlackAvatars } from "./slack.ts"
import "./db.ts" // initialise the SQLite schema at boot import "./db.ts" // initialise the SQLite schema at boot
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
const app = new Hono() const app = new Hono()
app.use("*", logger()) app.use("*", logger())
app.get("/api/health", (c) => c.json({ status: "ok" })) app.get("/api/health", (c) => c.json({ status: "ok" }))
app.get("/api/projects", async (c) => { app.get("/api/projects", async (c) => {
if (!hasLiveCredentials) { if (!hasNaptaCredentials) {
return c.json({ source: "fixture", projects: fixtureProjects }) return c.json({ source: "fixture", projects: fixtureProjects })
} }
const projects = await fetchNaptaProjects() try {
return c.json({ source: "napta", projects }) const projects = await fetchNaptaProjects()
return c.json({ source: "napta", projects })
} catch (err) {
console.error("fetchNaptaProjects failed:", err)
return c.json({ error: errorMessage(err) }, 502)
}
}) })
app.get("/api/projects/:id/members", async (c) => { app.get("/api/projects/:id/members", async (c) => {
const id = c.req.param("id") const id = c.req.param("id")
if (!hasLiveCredentials) { if (!hasNaptaCredentials) {
return c.json({ source: "fixture", members: fixtureMembers(id) }) return c.json({ source: "fixture", members: fixtureMembers(id) })
} }
const members = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(id)) try {
return c.json({ source: "napta", members }) const members = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(id))
return c.json({ source: "napta", members })
} catch (err) {
console.error("fetchNaptaProjectMembers failed:", err)
return c.json({ error: errorMessage(err) }, 502)
}
}) })
console.log( console.log(
`photofetch backend listening on :${config.port} (live credentials: ${hasLiveCredentials})`, `photofetch backend listening on :${config.port} (napta: ${hasNaptaCredentials})`,
) )
serve({ fetch: app.fetch, port: config.port }) serve({ fetch: app.fetch, port: config.port })

View File

@@ -1,15 +1,144 @@
import { config } from "./config.ts"
import type { Member, Project } from "./types.ts" import type { Member, Project } from "./types.ts"
// Real Napta integration. Implemented in Step 11 (build-out) once the exact // --- Auth: Auth0 client-credentials, with an in-memory JWT cache ---
// Napta API endpoints, auth header, and response shapes are confirmed in the
// walk-with-me design session. Until then these throw, and the route layer interface CachedToken {
// only calls them when live credentials are present — so the default token: string
// experience (no tokens) serves fixtures instead of erroring. expiresAt: number
}
let tokenCache: CachedToken | null = null
async function getAccessToken(): Promise<string> {
// Reuse the cached token until ~1 min before it expires.
if (tokenCache && Date.now() < tokenCache.expiresAt - 60_000) {
return tokenCache.token
}
const res = await fetch(config.naptaAuthUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: config.naptaClientId,
client_secret: config.naptaClientSecret,
audience: config.naptaAudience,
}),
})
if (!res.ok) {
throw new Error(`Napta auth failed: ${res.status} ${await res.text()}`)
}
const body = (await res.json()) as { access_token: string; expires_in: number }
tokenCache = {
token: body.access_token,
expiresAt: Date.now() + body.expires_in * 1000,
}
return body.access_token
}
// --- JSON:API helpers (Napta is JSON:API 1.0 over flask-rest-jsonapi) ---
interface JsonApiResource {
id: string
type: string
attributes: Record<string, unknown>
relationships?: Record<
string,
{ data: { id: string; type: string } | { id: string; type: string }[] | null }
>
}
interface JsonApiResponse {
data: JsonApiResource[]
included?: JsonApiResource[]
}
interface Filter {
name: string
op: string
val: unknown
}
interface QueryOpts {
filters?: Filter[]
include?: string[]
pageSize?: number
}
async function naptaGetPage(
path: string,
opts: QueryOpts,
page: number,
): Promise<JsonApiResponse> {
const token = await getAccessToken()
const url = new URL(`${config.naptaBaseUrl}${path}`)
// flask-rest-jsonapi: ?filter=[{"name","op","val"}], page[size], page[number], include.
if (opts.filters?.length) url.searchParams.set("filter", JSON.stringify(opts.filters))
if (opts.include?.length) url.searchParams.set("include", opts.include.join(","))
url.searchParams.set("page[size]", String(opts.pageSize ?? 100))
url.searchParams.set("page[number]", String(page))
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) {
// Surface the exact request + body so a live mismatch (filter syntax, etc.) is obvious.
throw new Error(`Napta GET ${url.pathname}${url.search} -> ${res.status} ${await res.text()}`)
}
return (await res.json()) as JsonApiResponse
}
// Page through a collection, accumulating `data` and sideloaded `included`.
async function naptaGetAll(
path: string,
opts: QueryOpts,
): Promise<{ data: JsonApiResource[]; included: JsonApiResource[] }> {
const data: JsonApiResource[] = []
const included: JsonApiResource[] = []
const pageSize = opts.pageSize ?? 100
for (let page = 1; page <= 1000; page++) {
const res = await naptaGetPage(path, opts, page)
data.push(...res.data)
if (res.included) included.push(...res.included)
if (res.data.length < pageSize) break // short page => last page
}
return { data, included }
}
function attr(resource: JsonApiResource, key: string): string {
const v = resource.attributes[key]
return v == null ? "" : String(v)
}
function relId(resource: JsonApiResource, name: string): string | null {
const rel = resource.relationships?.[name]?.data
if (!rel || Array.isArray(rel)) return null
return rel.id
}
// --- Projects ---
export async function fetchNaptaProjects(): Promise<Project[]> { export async function fetchNaptaProjects(): Promise<Project[]> {
throw new Error("Napta integration not implemented yet (Step 11)") const { data, included } = await naptaGetAll("/project", {
filters: [{ name: "is_archived", op: "eq", val: false }],
include: ["client"],
})
const clientNameById = new Map<string, string>()
for (const inc of included) {
if (inc.type === "client") clientNameById.set(inc.id, attr(inc, "name"))
}
return data
.map((p) => {
const clientId = relId(p, "client")
const clientName = clientId ? clientNameById.get(clientId) : undefined
return {
id: p.id,
name: attr(p, "name") || "Untitled project",
clientName: clientName || undefined,
}
})
.sort((a, b) => a.name.localeCompare(b.name))
} }
// --- Members (implemented in Slice 2) ---
export async function fetchNaptaProjectMembers(_projectId: string): Promise<Member[]> { export async function fetchNaptaProjectMembers(_projectId: string): Promise<Member[]> {
throw new Error("Napta integration not implemented yet (Step 11)") throw new Error("Napta member resolution not implemented yet (Slice 2)")
} }

View File

@@ -15,8 +15,9 @@ services:
- PORT=8000 - PORT=8000
- DB_PATH=/app/data/app.db - DB_PATH=/app/data/app.db
# Set these as env vars on the Coolify app to switch from demo fixtures # Set these as env vars on the Coolify app to switch from demo fixtures
# to live data. Empty -> the backend serves fixtures. # to live data. Empty Napta creds -> the backend serves fixtures.
- NAPTA_API_TOKEN=${NAPTA_API_TOKEN:-} - NAPTA_CLIENT_ID=${NAPTA_CLIENT_ID:-}
- NAPTA_CLIENT_SECRET=${NAPTA_CLIENT_SECRET:-}
- NAPTA_BASE_URL=${NAPTA_BASE_URL:-https://app.napta.io/api/v1} - NAPTA_BASE_URL=${NAPTA_BASE_URL:-https://app.napta.io/api/v1}
- SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN:-} - SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN:-}
volumes: volumes: