Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
10 changes: 9 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -57,3 +64,4 @@ export {
slackReadProvenance,
formatSlackReadProvenanceJson,
} from "./provenance.ts";

15 changes: 15 additions & 0 deletions src/keymaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | 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<string, string>;
}

Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
}

Expand All @@ -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;
Expand Down
34 changes: 29 additions & 5 deletions src/provenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,21 @@ 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;
}

/**
* 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,
Expand Down Expand Up @@ -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,
Expand All @@ -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<Record<string, unknown>>;
/** Internal parameters (empty for Slack reads). */
readonly internalParameters: Readonly<Record<string, unknown>>;
/** 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;
Expand Down
18 changes: 17 additions & 1 deletion src/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import { sha256BareHex } from "@bounded-systems/cas";
import {
checkPolicy,
type PolicyDecision,
type PolicyRole,
type PolicyState,
} from "@bounded-systems/policy";
Expand All @@ -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;
Expand All @@ -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<typeof checkPolicy>;

/** The content-addressed result of one read, ready for the provenance bridge (.6). */
export interface SlackReadEnvelope<Op extends SlackReadOp = SlackReadOp> {
/** 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;
Expand Down Expand Up @@ -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 extends SlackReadOp>(
op: Op,
params: SlackReadParams[Op],
Expand Down
1 change: 1 addition & 0 deletions src/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand Down
Loading