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;