@forwardimpact/landmark
Advanced tools
| /** | ||
| * `fit-landmark sources` — list activity row classes retained about an engineer. | ||
| * | ||
| * Iterates the SOURCE_CLASSES registry — one entry per RLS'd activity table — | ||
| * issuing an asc+desc pair through the authenticated Supabase client. RLS | ||
| * clamps both, so results reflect the caller's view. Classes with zero | ||
| * visible rows are filtered out; if all classes clamp to zero the command | ||
| * renders NO_SOURCES_FOR_PERSON. | ||
| */ | ||
| import { readRetention } from "@forwardimpact/map/activity/retention"; | ||
| import { EMPTY_STATES } from "../lib/empty-state.js"; | ||
| export const needsSupabase = true; | ||
| const CLASSES = [ | ||
| { | ||
| id: "organization_people", | ||
| label: "Profile", | ||
| // organization_people is the null-window class — its retention COMMENT is | ||
| // intentionally empty (admitted by activity._validate_retention_blob's | ||
| // organization_people exemption), so readRetention returns {window: null, | ||
| // clock: null}. The table's actual timestamp column is `updated_at`, so | ||
| // declare it here so the asc/desc probes in inventoryClass don't fall | ||
| // back to `imported_at` (which doesn't exist on this table). | ||
| clock: "updated_at", | ||
| plan: async (_s, e) => ({ | ||
| table: "organization_people", | ||
| filter: (q) => q.eq("email", e), | ||
| }), | ||
| }, | ||
| { | ||
| id: "evidence", | ||
| label: "Evidence", | ||
| plan: async (_s, e) => ({ | ||
| table: "evidence", | ||
| select: "created_at,github_artifacts!inner(email)", | ||
| filter: (q) => q.eq("github_artifacts.email", e), | ||
| }), | ||
| }, | ||
| { | ||
| id: "github_artifacts", | ||
| label: "GitHub artifacts", | ||
| plan: async (_s, e) => ({ | ||
| table: "github_artifacts", | ||
| filter: (q) => q.eq("email", e), | ||
| }), | ||
| }, | ||
| { | ||
| id: "getdx_snapshot_comments", | ||
| label: "GetDX comments", | ||
| plan: async (_s, e) => ({ | ||
| table: "getdx_snapshot_comments", | ||
| filter: (q) => q.eq("email", e), | ||
| }), | ||
| }, | ||
| { | ||
| id: "getdx_snapshot_team_scores", | ||
| label: "GetDX team scores", | ||
| plan: async (s, e) => { | ||
| const { data, error } = await s | ||
| .from("organization_people") | ||
| .select("getdx_team_id") | ||
| .eq("email", e) | ||
| .maybeSingle(); | ||
| if (error) throw error; | ||
| const t = data?.getdx_team_id; | ||
| return t | ||
| ? { | ||
| table: "getdx_snapshot_team_scores", | ||
| filter: (q) => q.eq("getdx_team_id", t), | ||
| } | ||
| : null; | ||
| }, | ||
| }, | ||
| { | ||
| id: "getdx_snapshots", | ||
| label: "GetDX snapshot cycles", | ||
| plan: async (s, e) => { | ||
| const { data, error } = await s.rpc("snapshot_ids_for_person", { | ||
| p_email: e, | ||
| }); | ||
| if (error) throw error; | ||
| const ids = (data ?? []).map((r) => r.snapshot_id); | ||
| return ids.length | ||
| ? { | ||
| table: "getdx_snapshots", | ||
| filter: (q) => q.in("snapshot_id", ids), | ||
| } | ||
| : null; | ||
| }, | ||
| }, | ||
| ]; | ||
| async function inventoryClass(supabase, cls, email) { | ||
| const plan = await cls.plan(supabase, email); | ||
| if (!plan) return null; | ||
| const ret = await readRetention(supabase, cls.id); | ||
| // Resolution order: retention metadata wins (single source of truth for | ||
| // classes that declare a window), then per-class override (for null-window | ||
| // classes that still have a usable timestamp column), then "imported_at" | ||
| // as the documented default the other five tables happen to use. | ||
| const clock = ret.clock ?? cls.clock ?? "imported_at"; | ||
| const sel = plan.select ?? `${clock}`; | ||
| const asc = plan.filter( | ||
| supabase | ||
| .from(plan.table) | ||
| .select(sel, { count: "exact" }) | ||
| .order(clock, { ascending: true }) | ||
| .limit(1), | ||
| ); | ||
| const desc = plan.filter( | ||
| supabase | ||
| .from(plan.table) | ||
| .select(sel) | ||
| .order(clock, { ascending: false }) | ||
| .limit(1), | ||
| ); | ||
| const [ascRes, descRes] = await Promise.all([asc, desc]); | ||
| if (ascRes.error) throw ascRes.error; | ||
| if (descRes.error) throw descRes.error; | ||
| const { data: oldRows, count } = ascRes; | ||
| const { data: newRows } = descRes; | ||
| if (!count) return null; | ||
| const oldest = oldRows[0]?.[clock] ?? null; | ||
| const newest = newRows[0]?.[clock] ?? null; | ||
| const falloff = ret.window && oldest ? addIso(oldest, ret.window) : null; | ||
| return { | ||
| id: cls.id, | ||
| label: cls.label, | ||
| count, | ||
| oldest, | ||
| newest, | ||
| window: ret.window, | ||
| falloff, | ||
| }; | ||
| } | ||
| /** | ||
| * Run the sources command — list activity row classes retained about `email`. | ||
| * | ||
| * @param {object} params | ||
| * @param {object} params.options | ||
| * @param {string} params.options.email | ||
| * @param {import("@supabase/supabase-js").SupabaseClient} params.supabase | ||
| * @param {string} [params.format] | ||
| * @returns {Promise<{view: {email: string, items: object[]}|null, meta: object}>} | ||
| */ | ||
| export async function runSourcesCommand({ options, supabase, format }) { | ||
| const email = options.email; | ||
| if (!email) throw new Error("sources: --email <e> is required"); | ||
| const items = []; | ||
| for (const cls of CLASSES) { | ||
| const item = await inventoryClass(supabase, cls, email); | ||
| if (item) items.push(item); | ||
| } | ||
| if (!items.length) { | ||
| return { | ||
| view: null, | ||
| meta: { | ||
| format, | ||
| emptyState: EMPTY_STATES.NO_SOURCES_FOR_PERSON(email), | ||
| }, | ||
| }; | ||
| } | ||
| return { view: { email, items }, meta: { format } }; | ||
| } | ||
| function addIso(ts, p) { | ||
| const m = /^P(\d+)([DWMY])$/.exec(p); | ||
| if (!m) return null; | ||
| const d = new Date(ts); | ||
| const n = Number(m[1]); | ||
| if (m[2] === "D") d.setUTCDate(d.getUTCDate() + n); | ||
| if (m[2] === "W") d.setUTCDate(d.getUTCDate() + 7 * n); | ||
| if (m[2] === "M") d.setUTCMonth(d.getUTCMonth() + n); | ||
| if (m[2] === "Y") d.setUTCFullYear(d.getUTCFullYear() + n); | ||
| return d.toISOString(); | ||
| } |
| /** | ||
| * Formatters for the `sources` command. | ||
| */ | ||
| import { renderHeader } from "./shared.js"; | ||
| /** Render the per-class source inventory as plain text. */ | ||
| export function toText(view) { | ||
| const lines = [renderHeader(`Sources retained about ${view.email}`), ""]; | ||
| for (const item of view.items) { | ||
| lines.push(` ${item.label} (${item.id})`); | ||
| lines.push(` count: ${item.count}`); | ||
| lines.push(` oldest: ${item.oldest ?? "—"}`); | ||
| lines.push(` newest: ${item.newest ?? "—"}`); | ||
| lines.push(` window: ${item.window ?? "while employed"}`); | ||
| if (item.falloff) lines.push(` falloff: ${item.falloff}`); | ||
| lines.push(""); | ||
| } | ||
| return lines.join("\n"); | ||
| } | ||
| /** Serialize the source inventory and metadata as formatted JSON. */ | ||
| export function toJson(view, meta) { | ||
| return JSON.stringify({ ...view, meta }, null, 2); | ||
| } | ||
| /** Render the source inventory as a markdown table. */ | ||
| export function toMarkdown(view) { | ||
| const lines = [ | ||
| `# Sources retained about ${view.email}`, | ||
| "", | ||
| "| Class | Count | Oldest | Newest | Window | Falloff |", | ||
| "| --- | --- | --- | --- | --- | --- |", | ||
| ]; | ||
| for (const item of view.items) { | ||
| lines.push( | ||
| `| ${item.label} | ${item.count} | ${item.oldest ?? "—"} | ${ | ||
| item.newest ?? "—" | ||
| } | ${item.window ?? "while employed"} | ${item.falloff ?? "—"} |`, | ||
| ); | ||
| } | ||
| lines.push(""); | ||
| return lines.join("\n"); | ||
| } |
| import { createHmac, timingSafeEqual } from "node:crypto"; | ||
| /** Thrown when no usable caller identity can be derived from the env. */ | ||
| export class IdentityUnresolvedError extends Error { | ||
| /** Wrap the reason in a prefixed message and attach code "LANDMARK_IDENTITY_UNRESOLVED". */ | ||
| constructor(reason) { | ||
| super(`Authentication required: ${reason}`); | ||
| this.code = "LANDMARK_IDENTITY_UNRESOLVED"; | ||
| } | ||
| } | ||
| // HS256 HMAC-SHA256 digest is fixed at 32 bytes; reject any signature whose | ||
| // decoded length deviates before invoking timingSafeEqual. | ||
| const HS256_DIGEST_BYTES = 32; | ||
| /** Decode a JWT segment as JSON; throws IdentityUnresolvedError on failure. */ | ||
| function parseJwtSegment(seg, label) { | ||
| let raw; | ||
| try { | ||
| raw = Buffer.from(seg, "base64url").toString("utf8"); | ||
| } catch { | ||
| throw new IdentityUnresolvedError( | ||
| `LANDMARK_AUTH_TOKEN ${label} is not valid base64url`, | ||
| ); | ||
| } | ||
| try { | ||
| return JSON.parse(raw); | ||
| } catch { | ||
| throw new IdentityUnresolvedError( | ||
| `LANDMARK_AUTH_TOKEN ${label} is not valid JSON`, | ||
| ); | ||
| } | ||
| } | ||
| /** | ||
| * Resolve the caller's identity from a Supabase Auth JWT in | ||
| * `LANDMARK_AUTH_TOKEN`. | ||
| * | ||
| * @param {NodeJS.ProcessEnv} [env] | ||
| * @returns {{email: string, jwt: string}} | ||
| * @throws {IdentityUnresolvedError} | ||
| */ | ||
| export function resolveIdentity(env = process.env) { | ||
| const jwt = env.LANDMARK_AUTH_TOKEN; | ||
| if (!jwt) | ||
| throw new IdentityUnresolvedError( | ||
| "LANDMARK_AUTH_TOKEN is not set. The Landmark CLI requires an authenticated caller.", | ||
| ); | ||
| const parts = jwt.split("."); | ||
| if (parts.length !== 3) | ||
| throw new IdentityUnresolvedError("LANDMARK_AUTH_TOKEN is not a JWT"); | ||
| // Header must announce HS256 + JWT — never trust an `alg: none` token, | ||
| // even if PostgREST would also reject it; we want the failure surface | ||
| // here so downstream code never sees a forged email. | ||
| const header = parseJwtSegment(parts[0], "header"); | ||
| if (header.alg !== "HS256" || header.typ !== "JWT") | ||
| throw new IdentityUnresolvedError( | ||
| "LANDMARK_AUTH_TOKEN header rejected (HS256 + JWT required)", | ||
| ); | ||
| const claims = parseJwtSegment(parts[1], "payload"); | ||
| if (typeof claims.email !== "string" || !claims.email) | ||
| throw new IdentityUnresolvedError( | ||
| "LANDMARK_AUTH_TOKEN missing string email claim", | ||
| ); | ||
| if (typeof claims.exp !== "number" || claims.exp * 1000 <= Date.now()) | ||
| throw new IdentityUnresolvedError("LANDMARK_AUTH_TOKEN is expired"); | ||
| // Defense in depth: when MAP_SUPABASE_JWT_SECRET is available (test | ||
| // harness, local dev) verify the HMAC ourselves before trusting any | ||
| // claim. Production engineer-side callers will not have the secret; | ||
| // for them the contract is that `email` is opaque until the first | ||
| // PostgREST round-trip succeeds — never log or branch on it before. | ||
| if (env.MAP_SUPABASE_JWT_SECRET) { | ||
| const actual = Buffer.from(parts[2], "base64url"); | ||
| if (actual.length !== HS256_DIGEST_BYTES) | ||
| throw new IdentityUnresolvedError( | ||
| "LANDMARK_AUTH_TOKEN signature does not verify", | ||
| ); | ||
| const expected = createHmac("sha256", env.MAP_SUPABASE_JWT_SECRET) | ||
| .update(`${parts[0]}.${parts[1]}`) | ||
| .digest(); | ||
| if (!timingSafeEqual(expected, actual)) | ||
| throw new IdentityUnresolvedError( | ||
| "LANDMARK_AUTH_TOKEN signature does not verify", | ||
| ); | ||
| } | ||
| return { email: claims.email, jwt }; | ||
| } |
+30
-0
@@ -28,5 +28,10 @@ #!/usr/bin/env node | ||
| import { runVoiceCommand } from "../src/commands/voice.js"; | ||
| import { runSourcesCommand } from "../src/commands/sources.js"; | ||
| import { resolveDataDir } from "../src/lib/cli.js"; | ||
| import { buildContext } from "../src/lib/context.js"; | ||
| import { SupabaseUnavailableError } from "../src/lib/supabase.js"; | ||
| import { | ||
| resolveIdentity, | ||
| IdentityUnresolvedError, | ||
| } from "../src/lib/identity.js"; | ||
| import { formatResult } from "../src/formatters/index.js"; | ||
@@ -56,2 +61,3 @@ | ||
| voice: { handler: runVoiceCommand, needsSupabase: true }, | ||
| sources: { handler: runSourcesCommand, needsSupabase: true }, | ||
| }; | ||
@@ -171,2 +177,12 @@ | ||
| }, | ||
| { | ||
| name: "sources", | ||
| description: "List activity row classes retained about an engineer", | ||
| options: { | ||
| email: { | ||
| type: "string", | ||
| description: "Email to inventory sources for", | ||
| }, | ||
| }, | ||
| }, | ||
| ], | ||
@@ -188,2 +204,3 @@ globalOptions: { | ||
| "fit-landmark health --manager alice@example.com", | ||
| "fit-landmark sources --email self@example.com", | ||
| ], | ||
@@ -222,2 +239,8 @@ documentation: [ | ||
| }, | ||
| { | ||
| title: "List Engineering Data Sources", | ||
| url: "https://www.forwardimpact.team/docs/products/engineering-data-sources/index.md", | ||
| description: | ||
| "List the activity rows retained about an engineer and their fall-off dates.", | ||
| }, | ||
| ], | ||
@@ -247,2 +270,4 @@ }; | ||
| const dataDir = resolveDataDir(values); | ||
| let identity = null; | ||
| if (entry.needsSupabase) identity = resolveIdentity(); | ||
| const ctx = await buildContext({ | ||
@@ -252,2 +277,3 @@ dataDir, | ||
| needsSupabase: entry.needsSupabase, | ||
| identity, | ||
| }); | ||
@@ -276,2 +302,6 @@ | ||
| } catch (error) { | ||
| if (error instanceof IdentityUnresolvedError) { | ||
| cli.error(error.message); | ||
| process.exit(4); | ||
| } | ||
| if (error instanceof SupabaseUnavailableError) { | ||
@@ -278,0 +308,0 @@ cli.error(error.message); |
+1
-1
| { | ||
| "name": "@forwardimpact/landmark", | ||
| "version": "0.1.14", | ||
| "version": "0.1.15", | ||
| "description": "Surface engineering progress from activity evidence — outcomes visible without singling out individuals.", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
@@ -20,2 +20,3 @@ /** | ||
| import * as voiceFormatter from "./voice.js"; | ||
| import * as sourcesFormatter from "./sources.js"; | ||
| const formatters = { | ||
@@ -33,2 +34,3 @@ org: orgFormatter, | ||
| voice: voiceFormatter, | ||
| sources: sourcesFormatter, | ||
| }; | ||
@@ -35,0 +37,0 @@ |
+12
-4
@@ -17,9 +17,17 @@ /** | ||
| * @param {boolean} params.needsSupabase - Whether to open a Supabase client. | ||
| * @returns {Promise<{mapData: object, supabase: object|null, format: string, options: object}>} | ||
| * @param {{email: string, jwt: string}|null} [params.identity] - Resolved caller identity (required when needsSupabase). | ||
| * @returns {Promise<{mapData: object, supabase: object|null, format: string, options: object, identity: object|null}>} | ||
| */ | ||
| export async function buildContext({ dataDir, options, needsSupabase }) { | ||
| export async function buildContext({ | ||
| dataDir, | ||
| options, | ||
| needsSupabase, | ||
| identity = null, | ||
| }) { | ||
| const mapData = await loadMapData(dataDir); | ||
| const supabase = needsSupabase ? createLandmarkClient() : null; | ||
| const supabase = needsSupabase | ||
| ? createLandmarkClient({ jwt: identity?.jwt }) | ||
| : null; | ||
| const format = resolveFormat(options); | ||
| return { mapData, supabase, format, options }; | ||
| return { mapData, supabase, format, options, identity }; | ||
| } |
@@ -32,2 +32,4 @@ /** | ||
| "No evidence data found. Evidenced depth reflects Guide-interpreted artifacts only.", | ||
| NO_SOURCES_FOR_PERSON: (email) => | ||
| `No sources retained for ${email} that you can see.`, | ||
| }; |
+23
-15
| /** | ||
| * Supabase client factory for Landmark. | ||
| * | ||
| * Mirrors the env-var contract used by Summit's createSummitClient. | ||
| * Landmark reads via the per-caller JWT minted under Supabase Auth — never | ||
| * the service-role key (criterion 3a). The service-role client lives only | ||
| * under products/map/src/ for ingestion. | ||
| */ | ||
@@ -19,23 +21,29 @@ | ||
| /** | ||
| * Create a Supabase client configured for Map's activity schema. | ||
| * Create a Supabase client bound to the caller's JWT. | ||
| * | ||
| * @param {object} [opts] | ||
| * @param {object} opts | ||
| * @param {string} opts.jwt - Supabase Auth JWT; transports as Authorization: Bearer. | ||
| * @param {string} [opts.url] - Override for MAP_SUPABASE_URL. | ||
| * @param {string} [opts.serviceRoleKey] - Override for MAP_SUPABASE_SERVICE_ROLE_KEY. | ||
| * @param {string} [opts.anonKey] - Override for MAP_SUPABASE_ANON_KEY. | ||
| * @param {string} [opts.schema] - Database schema (default: "activity"). | ||
| * @returns {import("@supabase/supabase-js").SupabaseClient} | ||
| */ | ||
| export function createLandmarkClient(opts = {}) { | ||
| const url = opts.url ?? process.env.MAP_SUPABASE_URL; | ||
| const key = opts.serviceRoleKey ?? process.env.MAP_SUPABASE_SERVICE_ROLE_KEY; | ||
| const schema = opts.schema ?? "activity"; | ||
| if (!url || !key) { | ||
| export function createLandmarkClient({ | ||
| jwt, | ||
| url = process.env.MAP_SUPABASE_URL, | ||
| anonKey = process.env.MAP_SUPABASE_ANON_KEY, | ||
| schema = "activity", | ||
| } = {}) { | ||
| if (!url || !anonKey) | ||
| throw new SupabaseUnavailableError( | ||
| "MAP_SUPABASE_URL / MAP_SUPABASE_SERVICE_ROLE_KEY not set. " + | ||
| "Run `fit-map activity start` and export the URL + key it prints.", | ||
| "MAP_SUPABASE_URL / MAP_SUPABASE_ANON_KEY not set. Run `fit-map activity start` and export the URL + anon key it prints.", | ||
| ); | ||
| } | ||
| return createClient(url, key, { db: { schema } }); | ||
| if (!jwt) | ||
| throw new SupabaseUnavailableError( | ||
| "missing JWT — resolveIdentity must run first", | ||
| ); | ||
| return createClient(url, anonKey, { | ||
| db: { schema }, | ||
| global: { headers: { Authorization: `Bearer ${jwt}` } }, | ||
| }); | ||
| } | ||
@@ -42,0 +50,0 @@ |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
122009
10.91%38
8.57%3271
11.91%5
25%