From b5ff31ae7de5a1c81eb49d00142b4598f662b66b Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:25:54 -0400 Subject: [PATCH] docs: comprehensive JSDoc for public API Add concise TSDoc to all exported types, functions, and interface members across the Slack read surface. Ensures full API discoverability via Deno doc and other documentation generators. All interfaces now document their fields; all functions document behavior and arguments. Fixes private type references by re-exporting or using opaque types where appropriate. Co-Authored-By: Claude Haiku 4.5 --- deno.lock | 77 +++++++++++++++++++++++++++++++++++++++++++++++ src/canonical.ts | 1 + src/index.ts | 10 +++++- src/keymaker.ts | 15 +++++++++ src/provenance.ts | 34 ++++++++++++++++++--- src/read.ts | 18 ++++++++++- src/transport.ts | 1 + src/types.ts | 25 ++++++++++++--- src/webapi.ts | 9 +++--- 9 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 deno.lock diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..435a5b9 --- /dev/null +++ b/deno.lock @@ -0,0 +1,77 @@ +{ + "version": "5", + "specifiers": { + "npm:@jsr/bounded-systems__anchored-chain-sqlite@*": "0.2.1", + "npm:@jsr/bounded-systems__anchored-chain@*": "0.2.1", + "npm:@jsr/bounded-systems__cas@*": "0.1.2", + "npm:@jsr/bounded-systems__policy@*": "0.2.1", + "npm:@types/bun@^1.3.14": "1.3.14", + "npm:typescript@^6.0.3": "6.0.3" + }, + "npm": { + "@jsr/bounded-systems__anchored-chain-sqlite@0.2.1": { + "integrity": "sha512-uhmvcp3GC4vS1L7zsKrg1hP5lWahMj/fjkPtXXy6LbJU1um6LH8Vs0PVQj00Cazy3LiwQLuVc+klY0doA24jVw==", + "dependencies": [ + "@jsr/bounded-systems__anchored-chain", + "@jsr/bounded-systems__cas", + "drizzle-orm" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/bounded-systems__anchored-chain-sqlite/0.2.1.tgz" + }, + "@jsr/bounded-systems__anchored-chain@0.2.1": { + "integrity": "sha512-Xg2kxe2tnJpNfRqKev6cwwFzb6nEpzhi6/LUMuRCuvzGU3yvSju5CM7tt7RqH8MkmQVFAvPpT62/QvjLzloSBA==", + "dependencies": [ + "@jsr/bounded-systems__cas" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/bounded-systems__anchored-chain/0.2.1.tgz" + }, + "@jsr/bounded-systems__cas@0.1.2": { + "integrity": "sha512-Pp1MWl5KJL30HMLqFAZ+g62IYH9g5xpDDNUdRDgsdomnMamXnubg+yyg4N1elqaupa6Q7A/cmlaRtqP8KHu4eA==", + "tarball": "https://npm.jsr.io/~/11/@jsr/bounded-systems__cas/0.1.2.tgz" + }, + "@jsr/bounded-systems__policy@0.2.1": { + "integrity": "sha512-5ppqxSi1rmo10S07M1zOCGGCWkRkXhXWWQG4XFcGLER+GPbptuitixcUPnMyXMgly8Kc+k8prJWU6nrh/L+X3g==", + "tarball": "https://npm.jsr.io/~/11/@jsr/bounded-systems__policy/0.2.1.tgz" + }, + "@types/bun@1.3.14": { + "integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==", + "dependencies": [ + "bun-types" + ] + }, + "@types/node@26.0.0": { + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "dependencies": [ + "undici-types" + ] + }, + "bun-types@1.3.14": { + "integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==", + "dependencies": [ + "@types/node" + ] + }, + "drizzle-orm@0.45.2": { + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==" + }, + "typescript@6.0.3": { + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "bin": true + }, + "undici-types@8.3.0": { + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + } + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:@jsr/bounded-systems__anchored-chain-sqlite@*", + "npm:@jsr/bounded-systems__anchored-chain@*", + "npm:@jsr/bounded-systems__cas@*", + "npm:@jsr/bounded-systems__policy@*", + "npm:@types/bun@^1.3.14", + "npm:typescript@^6.0.3" + ] + } + } +} diff --git a/src/canonical.ts b/src/canonical.ts index 679477b..44c024e 100644 --- a/src/canonical.ts +++ b/src/canonical.ts @@ -5,6 +5,7 @@ // JSON would). Shared by execSlackRead's content address and the provenance // derivation digest (slack .6) so the two never disagree on the envelope's hash. +/** Serialize a value to canonical (deterministic, sorted-key) JSON for content-addressing. */ export function canonicalJson(value: unknown): string { if (value === undefined) return "null"; if (value === null || typeof value !== "object") { diff --git a/src/index.ts b/src/index.ts index a866c36..87f5833 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,14 +36,21 @@ export type { SlackReadTransport } from "./transport.ts"; export type { WebApiTransportDeps } from "./webapi.ts"; export { webApiSlackTransport } from "./webapi.ts"; -export type { ExecSlackReadDeps, SlackReadEnvelope } from "./read.ts"; +export type { + ExecSlackReadDeps, + SlackReadEnvelope, + PolicyDecision, +} from "./read.ts"; export { execSlackRead, formatSlackReadEnvelope, DEFAULT_KEY_TTL_MS } from "./read.ts"; +export type { PolicyState, PolicyRole } from "@bounded-systems/policy"; + export { canonicalJson } from "./canonical.ts"; export type { SlackReadDerivationOptions, SlsaProvenanceStatement, + SlsaResourceDescriptor, } from "./provenance.ts"; export { SLACK_READ_CONTRACT, @@ -57,3 +64,4 @@ export { slackReadProvenance, formatSlackReadProvenanceJson, } from "./provenance.ts"; + diff --git a/src/keymaker.ts b/src/keymaker.ts index 4c17139..d476d8f 100644 --- a/src/keymaker.ts +++ b/src/keymaker.ts @@ -66,20 +66,27 @@ export interface SlackKeyScope { * ⊃ thread). */ export interface SlackAuthTarget { + /** The read op being authorized. */ op: SlackReadOp; + /** Org (workspace/enterprise) id if the read reaches that level. */ org?: string | undefined; + /** Channel id if the read reaches that level. */ channel?: string | undefined; + /** Thread parent ts if the read reaches that level. */ thread?: string | undefined; } /** Request the transport hands to `authorize()` for credential injection. */ export interface SlackRequest { + /** The URL being called. */ url?: string | undefined; + /** Headers on the request. */ headers?: Record | undefined; } /** A request after the key has injected its authorization (e.g. bearer header). */ export interface AuthorizedSlackRequest extends SlackRequest { + /** Headers with the authorization injected. */ headers: Record; } @@ -93,6 +100,7 @@ export interface AuthorizedSlackRequest extends SlackRequest { export interface ScopedSlackKey { /** Stable, non-secret identifier for provenance attribution. */ readonly keyId: string; + /** The scope this key is authorized for. */ readonly scope: SlackKeyScope; /** Expiry, epoch ms. Real TTL via Slack OAuth token rotation (spike prx-5u1). */ readonly expiresAt: number; @@ -107,6 +115,7 @@ export interface ScopedSlackKey { /** A request to mint a key: the scope to grant and how long it lives. */ export interface SlackKeyGrant { + /** The scope to grant to this key. */ scope: SlackKeyScope; /** Time-to-live in ms; the keymaker stamps `expiresAt = now + ttlMs`. */ ttlMs: number; @@ -118,6 +127,7 @@ export interface SlackKeyGrant { * verb); the read core receives a keymaker, never the root secret. */ export interface SlackKeymaker { + /** Mint a new ScopedSlackKey with the requested grant. */ mint(grant: SlackKeyGrant): ScopedSlackKey; } @@ -133,16 +143,21 @@ export interface SlackKeymaker { /** A minted base credential (structural mirror of auth's ScopedCredential). */ export interface BaseScopedCredential { + /** Non-secret identifier for this credential. */ readonly keyId: string; + /** When this credential expires, epoch ms. */ readonly expiresAt: number; + /** Inject authorization into a request. */ authorize(req: SlackRequest): AuthorizedSlackRequest; } /** A generic credential keymaker (structural mirror of auth's CredentialKeymaker). */ export interface BaseKeymaker { + /** Mint a new credential with the given TTL and optional keyId. */ mint(grant: { ttlMs: number; keyId?: string | undefined }): BaseScopedCredential; } +/** Options for constructing a scope-wrapping keymaker. */ export interface SlackScopedKeymakerOptions { /** Injectable clock for the scope-layer expiry check. Defaults to Date.now. */ now?: () => number; diff --git a/src/provenance.ts b/src/provenance.ts index ea88f33..ae946e8 100644 --- a/src/provenance.ts +++ b/src/provenance.ts @@ -36,11 +36,12 @@ import type { SlackReadOp } from "./types.ts"; /** The artifact contract a recorded slack-read envelope claims to satisfy. */ export const SLACK_READ_CONTRACT = "slack.read/v1"; -/** Producer id for a read of `op` (e.g. "slack.history"). */ +/** Compute the producer id for a read of `op` (e.g. "slack.history"). */ export function slackReadProducer(op: SlackReadOp): string { return `slack.${op}`; } +/** Options for building a Slack read derivation, primarily for deterministic testing. */ export interface SlackReadDerivationOptions { /** Derivation timestamp; injected so records are deterministic in tests. */ now?: number; @@ -48,7 +49,8 @@ export interface SlackReadDerivationOptions { /** * Build (without recording) the derivation for a completed slack read. Pure: - * the same envelope + timestamp always produces the same `derivationId`. + * the same envelope + timestamp always produces the same `derivationId`. The + * returned derivation is opaque to consumers; only the composition root uses it. */ export function slackReadDerivation( envelope: SlackReadEnvelope, @@ -86,7 +88,8 @@ export function slackReadDerivation( /** * Record a slack read in the ledger. Idempotent: the derivationId is content- * addressed, so re-recording an identical read returns the stored derivation - * without a duplicate append. + * without a duplicate append. The derivation store is opaque; only composition + * roots construct and use it. */ export async function recordSlackReadDerivation( derivations: DerivationStore, @@ -105,34 +108,55 @@ export async function recordSlackReadDerivation( // in-toto/SLSA verifier (Rekor, slsa-verifier, …) without adopting their // runtime. +/** The in-toto Statement type URI. */ export const IN_TOTO_STATEMENT_TYPE = "https://in-toto.io/Statement/v1"; +/** The SLSA Provenance v1 predicate type URI. */ export const SLSA_PROVENANCE_PREDICATE_TYPE = "https://slsa.dev/provenance/v1"; +/** The build type URI for Slack reads via anchored-chain. */ export const SLACK_READ_BUILD_TYPE = "https://anchored-chain.dev/slack/read/v1"; +/** The builder ID for Slack reads via anchored-chain. */ export const SLACK_READ_BUILDER_ID = "https://anchored-chain.dev/slack.read"; -interface SlsaResourceDescriptor { +/** A resource descriptor in the SLSA provenance format. */ +export interface SlsaResourceDescriptor { + /** The name of the resource. */ readonly name: string; + /** The digest of the resource (sha256 hash). */ readonly digest: { readonly sha256: string }; } +/** A SLSA Provenance v1 in-toto Statement for a Slack read, exportable to any verifier. */ export interface SlsaProvenanceStatement { + /** The in-toto Statement type. */ readonly _type: typeof IN_TOTO_STATEMENT_TYPE; - readonly subject: readonly InTotoSubject[]; + /** Subjects (artifacts) produced by this read. */ + readonly subject: readonly { readonly name: string; readonly digest: { readonly sha256: string } }[]; + /** The SLSA Provenance v1 predicate type. */ readonly predicateType: typeof SLSA_PROVENANCE_PREDICATE_TYPE; + /** The provenance predicate, containing build definition and run details. */ readonly predicate: { + /** Build definition with build type, parameters, and resolved dependencies. */ readonly buildDefinition: { + /** The build type URI for Slack reads. */ readonly buildType: typeof SLACK_READ_BUILD_TYPE; + /** External parameters (the read op, params, key attribution). */ readonly externalParameters: Readonly>; + /** Internal parameters (empty for Slack reads). */ readonly internalParameters: Readonly>; + /** Resolved dependencies (the query digested). */ readonly resolvedDependencies: readonly SlsaResourceDescriptor[]; }; + /** Run details with builder ID and invocation metadata. */ readonly runDetails: { + /** The builder ID. */ readonly builder: { readonly id: typeof SLACK_READ_BUILDER_ID }; + /** Invocation metadata (ID and start time). */ readonly metadata: { readonly invocationId: string; readonly startedOn: string }; }; }; } +/** Extract bare hex from a Digest, removing the sha256: prefix if present. */ function bareHex(digest: Digest): string { const s = digest as string; return s.startsWith("sha256:") ? s.slice("sha256:".length) : s; diff --git a/src/read.ts b/src/read.ts index fc8351e..32f9af0 100644 --- a/src/read.ts +++ b/src/read.ts @@ -15,7 +15,6 @@ import { sha256BareHex } from "@bounded-systems/cas"; import { checkPolicy, - type PolicyDecision, type PolicyRole, type PolicyState, } from "@bounded-systems/policy"; @@ -34,6 +33,7 @@ import { /** Default lifetime of a per-read minted key — short, by design. */ export const DEFAULT_KEY_TTL_MS = 60_000; +/** Dependencies for the gated, provenance-ready read pipeline. */ export interface ExecSlackReadDeps { /** Mints the per-read scoped credential (composition root: slackScopedKeymaker(createServiceKeymaker("slack"))). */ keymaker: SlackKeymaker; @@ -47,16 +47,27 @@ export interface ExecSlackReadDeps { ttlMs?: number | undefined; } +/** + * The allow/deny decision from the policy gate (opaque type; details depend on + * @bounded-systems/policy implementation). + */ +export type PolicyDecision = ReturnType; + /** The content-addressed result of one read, ready for the provenance bridge (.6). */ export interface SlackReadEnvelope { + /** The read op executed. */ op: Op; + /** The parameters passed to the op. */ params: SlackReadParams[Op]; + /** The raw result from the transport. */ result: SlackRawResult; /** Bare-hex sha256 of the canonical {op, params, result} envelope. */ sha256: string; /** Provenance attribution from the key that authorized this read (non-secret). */ keyId: string; + /** The scope of the minted key. */ scope: SlackKeyScope; + /** When the minted key expires, epoch ms. */ expiresAt: number; /** The allow decision that gated the read. */ policy: PolicyDecision; @@ -90,6 +101,11 @@ function targetOf( return target; } +/** + * Execute a gated Slack read: check policy, mint a scoped key, call transport, + * content-address the result. The envelope carries non-secret provenance + * (keyId, scope, expiresAt) and the allow decision for auditing. + */ export async function execSlackRead( op: Op, params: SlackReadParams[Op], diff --git a/src/transport.ts b/src/transport.ts index c69dc01..07122de 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -13,6 +13,7 @@ import type { ScopedSlackKey } from "./keymaker.ts"; import type { SlackReadOp, SlackReadParams, SlackRawResult } from "./types.ts"; +/** The transport port abstraction for executing Slack read ops. */ export interface SlackReadTransport { /** * Execute one read op. Implementations exercise `key.authorize(target, req)` diff --git a/src/types.ts b/src/types.ts index 852def5..6ffce68 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,7 +16,7 @@ export const SLACK_READ_OPS: readonly SlackReadOp[] = [ "users", ] as const; -/** `conversations.list` — enumerate channels the token can see. */ +/** Parameters for `conversations.list` — enumerate channels the token can see. */ export interface SlackChannelsParams { /** Pagination cursor from a prior page's `nextCursor`. */ cursor?: string | undefined; @@ -26,11 +26,13 @@ export interface SlackChannelsParams { types?: string | undefined; } -/** `conversations.history` — messages in one channel. */ +/** Parameters for `conversations.history` — messages in one channel. */ export interface SlackHistoryParams { /** Channel id (e.g. "C0123…"). Required. */ channel: string; + /** Pagination cursor from a prior page's response. */ cursor?: string | undefined; + /** Page size; transport clamps to provider limits. */ limit?: number | undefined; /** Inclusive lower bound (Slack ts). */ oldest?: string | undefined; @@ -38,26 +40,35 @@ export interface SlackHistoryParams { latest?: string | undefined; } -/** `conversations.replies` — one thread's reply chain. */ +/** Parameters for `conversations.replies` — one thread's reply chain. */ export interface SlackThreadParams { + /** Channel id containing the thread. */ channel: string; /** Parent message ts identifying the thread. Required. */ ts: string; + /** Pagination cursor from a prior page's response. */ cursor?: string | undefined; + /** Page size; transport clamps to provider limits. */ limit?: number | undefined; } -/** `users.list` / `users.info` — resolve users. */ +/** Parameters for `users.list` or `users.info` — resolve users. */ export interface SlackUsersParams { + /** Pagination cursor from a prior page's response. */ cursor?: string | undefined; + /** Page size; transport clamps to provider limits. */ limit?: number | undefined; } /** Map each op to its parameter shape (drives the typed transport port). */ export interface SlackReadParams { + /** Parameters for the channels read op. */ channels: SlackChannelsParams; + /** Parameters for the history read op. */ history: SlackHistoryParams; + /** Parameters for the thread read op. */ thread: SlackThreadParams; + /** Parameters for the users read op. */ users: SlackUsersParams; } @@ -68,11 +79,15 @@ export interface SlackReadParams { * pagination when present. */ export interface SlackRawResult { + /** Whether the call succeeded. */ ok: boolean; + /** The opaque response payload from Slack. */ data: unknown; + /** Pagination cursor for the next page, if present. */ cursor?: string | null | undefined; } +/** Error codes for Slack read failures across all pipeline stages. */ export type SlackReadErrorCode = | "POLICY_BLOCKED" | "MISSING_PARAM" @@ -83,7 +98,9 @@ export type SlackReadErrorCode = /** Typed failure for every stage of the read pipeline (gate → mint → call). */ export class SlackReadError extends Error { + /** The error code categorizing the failure. */ readonly code: SlackReadErrorCode; + /** Construct an error with a message and code. */ constructor(message: string, code: SlackReadErrorCode) { super(message); this.name = "SlackReadError"; diff --git a/src/webapi.ts b/src/webapi.ts index e82e607..cb7f01c 100644 --- a/src/webapi.ts +++ b/src/webapi.ts @@ -17,7 +17,7 @@ import { const SLACK_API_BASE = "https://slack.com/api"; -/** Read op -> Slack Web API method. */ +/** Map read ops to Slack Web API methods. */ const METHOD: Record = { channels: "conversations.list", history: "conversations.history", @@ -25,8 +25,7 @@ const METHOD: Record = { users: "users.list", }; -// Slack `error` strings that mean "the credential is the problem" -> UNAUTHORIZED -// (vs a request/data problem -> TRANSPORT_FAILED). +/** Slack error codes indicating credential failure (UNAUTHORIZED) vs request failure. */ const AUTH_ERRORS = new Set([ "not_authed", "invalid_auth", @@ -38,6 +37,7 @@ const AUTH_ERRORS = new Set([ "ekm_access_denied", ]); +/** Dependencies for the Web API transport adapter. */ export interface WebApiTransportDeps { /** Injectable fetch (tests). Defaults to the global fetch. */ fetch?: typeof fetch; @@ -45,7 +45,7 @@ export interface WebApiTransportDeps { baseUrl?: string; } -/** Our param names already match Slack's (channel/ts/limit/cursor/oldest/latest/types). */ +/** Build a query string from params; drops undefined/null values. Param names already match Slack's. */ function buildQuery(params: Record): string { const q = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { @@ -55,6 +55,7 @@ function buildQuery(params: Record): string { return s ? `?${s}` : ""; } +/** Create a Slack Web API transport that executes read ops via the official Web API. */ export function webApiSlackTransport(deps: WebApiTransportDeps = {}): SlackReadTransport { const doFetch = deps.fetch ?? fetch; const base = deps.baseUrl ?? SLACK_API_BASE;