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:
@@ -3,14 +3,19 @@
|
||||
|
||||
PORT=8000
|
||||
|
||||
# Napta API — required for live data. Without it, the API serves demo fixtures.
|
||||
# Base URL + token: see https://app.napta.io (Settings → API).
|
||||
NAPTA_API_TOKEN=
|
||||
# Napta API (https://app.napta.io). Auth is Auth0 Machine-to-Machine: the backend
|
||||
# exchanges client_id + client_secret for a JWT. Without BOTH, the API serves demo
|
||||
# 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
|
||||
# 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
|
||||
# Used to resolve each person's profile picture by email.
|
||||
# Optional — without it, Members show initials Avatars instead of Slack photos.
|
||||
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
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
export interface Config {
|
||||
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
|
||||
naptaAuthUrl: string
|
||||
naptaAudience: string
|
||||
slackBotToken: string | null
|
||||
}
|
||||
|
||||
export const config: Config = {
|
||||
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",
|
||||
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,
|
||||
}
|
||||
|
||||
// Live data needs BOTH a Napta token (to list projects/people) and a Slack
|
||||
// token (to resolve avatars). Without both, the API serves demo fixtures.
|
||||
export const hasLiveCredentials: boolean = Boolean(
|
||||
config.naptaApiToken && config.slackBotToken,
|
||||
// Listing Projects and Members needs Napta. Without it, the API serves fixtures.
|
||||
export const hasNaptaCredentials: boolean = Boolean(
|
||||
config.naptaClientId && config.naptaClientSecret,
|
||||
)
|
||||
|
||||
// Slack only resolves Avatars; it's optional and degrades to initials when absent.
|
||||
export const hasSlackCredentials: boolean = Boolean(config.slackBotToken)
|
||||
|
||||
@@ -1,35 +1,49 @@
|
||||
import { serve } from "@hono/node-server"
|
||||
import { Hono } from "hono"
|
||||
import { logger } from "hono/logger"
|
||||
import { config, hasLiveCredentials } from "./config.ts"
|
||||
import { config, hasNaptaCredentials } from "./config.ts"
|
||||
import { fixtureMembers, fixtureProjects } from "./fixtures.ts"
|
||||
import { fetchNaptaProjectMembers, fetchNaptaProjects } from "./napta.ts"
|
||||
import { enrichWithSlackAvatars } from "./slack.ts"
|
||||
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()
|
||||
app.use("*", logger())
|
||||
|
||||
app.get("/api/health", (c) => c.json({ status: "ok" }))
|
||||
|
||||
app.get("/api/projects", async (c) => {
|
||||
if (!hasLiveCredentials) {
|
||||
if (!hasNaptaCredentials) {
|
||||
return c.json({ source: "fixture", projects: fixtureProjects })
|
||||
}
|
||||
const projects = await fetchNaptaProjects()
|
||||
return c.json({ source: "napta", projects })
|
||||
try {
|
||||
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) => {
|
||||
const id = c.req.param("id")
|
||||
if (!hasLiveCredentials) {
|
||||
if (!hasNaptaCredentials) {
|
||||
return c.json({ source: "fixture", members: fixtureMembers(id) })
|
||||
}
|
||||
const members = await enrichWithSlackAvatars(await fetchNaptaProjectMembers(id))
|
||||
return c.json({ source: "napta", members })
|
||||
try {
|
||||
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(
|
||||
`photofetch backend listening on :${config.port} (live credentials: ${hasLiveCredentials})`,
|
||||
`photofetch backend listening on :${config.port} (napta: ${hasNaptaCredentials})`,
|
||||
)
|
||||
serve({ fetch: app.fetch, port: config.port })
|
||||
|
||||
@@ -1,15 +1,144 @@
|
||||
import { config } from "./config.ts"
|
||||
import type { Member, Project } from "./types.ts"
|
||||
|
||||
// Real Napta integration. Implemented in Step 11 (build-out) once the exact
|
||||
// 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
|
||||
// only calls them when live credentials are present — so the default
|
||||
// experience (no tokens) serves fixtures instead of erroring.
|
||||
// --- Auth: Auth0 client-credentials, with an in-memory JWT cache ---
|
||||
|
||||
interface CachedToken {
|
||||
token: string
|
||||
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[]> {
|
||||
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[]> {
|
||||
throw new Error("Napta integration not implemented yet (Step 11)")
|
||||
throw new Error("Napta member resolution not implemented yet (Slice 2)")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user