diff --git a/README.md b/README.md index 22fcc95..2582b14 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Shared contracts and runtime libraries for CommerceKit GitHub Actions. This public repository is a pnpm monorepo, not a GitHub Marketplace Action. There is no root `action.yml`. -The source manifests prepare `notification-contract@0.0.5` (unpublished candidate) and `ccv2-client@0.0.3` under the `preview` dist-tag. Source versions are not registry publication evidence. APIs remain pre-1.0; verify registry publication before installation. Placeholder reserved versions, if published later, are not supported dependencies. +The source manifests prepare `notification-contract@0.0.6` (unpublished candidate) and `ccv2-client@0.0.3` under the `preview` dist-tag. Source versions are not registry publication evidence. APIs remain pre-1.0; verify registry publication before installation. Placeholder reserved versions, if published later, are not supported dependencies. | Package | Responsibility | Consumers | | ------------------------------------------ | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | @@ -41,3 +41,8 @@ See [CONTRIBUTING](CONTRIBUTING.md) for individual commands, [compatibility poli Package reservation, preview/stable publication and GitHub Releases are separate manually confirmed owner workflows. Publication never runs on push or PR. [RELEASING](RELEASING.md) describes two-phase recovery and coordinated stable gates. No package or Action is published by this implementation PR. The manual publish workflow verifies the exact commit's CI/CodeQL results and publishes the tested tarball after an integrity check. A separate manual workflow verifies registry publication before creating a package-specific Git tag and GitHub Release. See [dispatch examples](RELEASING.md#dispatch-examples-after-merge-and-owner-approval). Tags do not trigger publication. + +The [consistent Action reporting candidate](docs/action-reporting.md) adds shared +input snapshots, status styling, GitHub summary formatting and setup events. +Published 0.0.5 remains the registry baseline; candidate consumer readiness is +blocked until separately authorized 0.0.6 publication and normal repinning. diff --git a/compatibility/tests/release-safety.test.mjs b/compatibility/tests/release-safety.test.mjs index 5b3b250..6cf68c4 100644 --- a/compatibility/tests/release-safety.test.mjs +++ b/compatibility/tests/release-safety.test.mjs @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; it('publication requires exact package/channel/confirmation and pre-1.0 version', () => { for (const [name, version] of [ - ['notification-contract', '0.0.5'], + ['notification-contract', '0.0.6'], ['ccv2-client', '0.0.3'], ]) expect(releasePlan(name, 'preview', 'PUBLISH-COMMERCEKIT-PREVIEW')).toMatchObject({ diff --git a/compatibility/tests/types.ts b/compatibility/tests/types.ts index 37624de..58271cb 100644 --- a/compatibility/tests/types.ts +++ b/compatibility/tests/types.ts @@ -93,3 +93,41 @@ const invalidCleanupInputs: BuildCleanupInputSnapshot = { void invalidCleanupInputs; // @ts-expect-error cleanup warning support does not widen Deploy status createDeploymentEvent({ status: 'warning', data: {} }); + +import { + createSetupEvent, + createActionInputContext, + captureActionInputs, + renderActionSummary, + getNotificationStyle, + type SetupEvent, + type NotificationInputContext, + type ActionInputDefinition, + type ActionSummaryOptions, +} from '../../packages/notification-contract/src/index.js'; +const definitions: readonly ActionInputDefinition[] = [{ name: 'version' }]; +const captured = captureActionInputs( + definitions, + () => 'latest', + () => {}, +); +const inputContext: NotificationInputContext = createActionInputContext(definitions, captured, { + validated: true, +}); +const setup: SetupEvent = createSetupEvent({ + status: 'success', + data: { ...inputContext, version: '1.0.0' }, +}); +const summaryOptions: ActionSummaryOptions = { + title: 'Setup CommerceKit', + status: setup.status, + inputContext, + facts: [{ label: 'Version', value: '1.0.0' }], +}; +const summary: string = renderActionSummary(summaryOptions); +void summary; +void getNotificationStyle('warning'); +// @ts-expect-error setup cannot use a deployment handoff status +createSetupEvent({ status: 'action-required', data: {} }); +// @ts-expect-error snapshots are display strings rather than raw objects +createActionInputContext(definitions, { version: {} }, { validated: false }); diff --git a/docs/action-reporting.md b/docs/action-reporting.md new file mode 100644 index 0000000..457e54c --- /dev/null +++ b/docs/action-reporting.md @@ -0,0 +1,121 @@ +# Consistent Action reporting candidate + +The five release-candidate Actions are setup-commercekit, ccv2-build, +ccv2-deploy, ccv2-build-cleanup and notify. They share this reporting contract +through the existing notification-contract package. No new package, authentication +implementation, provider delivery, schedule or SAP operation is introduced. + +## Visual contract + +| Status | Icon | Meaning | +| --------------- | ---- | ----------------------------------------------------- | +| success | ✅ | The reported operation succeeded | +| failure | ❌ | The operation failed | +| warning | ⚠️ | The producer identified an issue that needs attention | +| action-required | ⚠️ | A deployment requires an explicit decision | +| internal-error | ❌ | The Action could not complete its operation | +| started | ℹ️ | The operation started | +| cancelled | ⏹️ | The operation was cancelled | + +`getNotificationStyle` owns labels, icons, semantic tones and accent colors. +Cancellation remains neutral. A warning never changes a failed build/deployment +into success. Build and Deploy completed-status types remain unchanged. + +Cards and summaries present the operation title/status, result facts, inputs, +issues and workflow reference in that order. Provider layouts retain native +Slack blocks and Teams containers/fact sets. GitHub summaries use escaped +Markdown headings and tables. Consumers provide meaningful human labels rather +than dumping raw objects or internal property names. + +`renderActionSummary` is pure. The caller writes its result through the existing +GitHub Actions summary API on handled success, failure and warning. Summary or +notification generation failure must not replace the underlying operation result. +A delivery Action reports its own delivery outcome separately from the source +event severity: successful delivery of a failed build is successful delivery. + +## Safe input context + +`captureActionInputs` reads configured confidential fields first and registers +each value before reading public settings. `createActionInputContext` includes +every declared input name, effective values after validation, and safely captured +supplied values when validation failed. Exact input coverage is checked against +each Action's metadata by consumer tests. + +- Authentication fields, webhook URLs, credentials and secret-bearing input + names always display `[REDACTED]`, including missing or invalid values. +- Known secrets and their encoded forms, Bearer/PAT/JWT-like values and + credential-bearing/query URLs are redacted before bounding display values. +- Event JSON, event-file paths, traffic JSON and free-form decision/cancellation + reasons show presence only. Arbitrary payloads are never copied into cards. +- Public API origins may be shown after strict origin validation. Authentication + token endpoints remain fully redacted. +- Display values are bounded to 256 Unicode code points. Truncation is explicit; + `inputsValidated` and `inputsTruncated` distinguish safe previews from execution + configuration. Provider previews may be shorter with a visible note. +- Common snapshots permit at most 50 kebab-case names and enforce confidential + placeholders in the schema. Cleanup's existing exact 14-name schema is retained. + +The common summary renderer escapes HTML, Markdown, table separators, mentions +and newlines. It masks confidential names defensively even for a forged snapshot, +filters private links and preserves safe enterprise workflow URLs. Result tables +and decision tables are bounded to 50 rows, sections to eight, columns to eight, +links to three and the complete UTF-8 summary to 64 KiB, with an omission notice. + +## Additive event support + +Check, Build and Deploy data gain optional input-context and safe issue fields; +old payloads remain valid. Setup gains `commercekit.setup.completed`, public +`SetupEvent`/`SetupEventData` types and `createSetupEvent`. It records requested and +resolved version, target, build ID, cache result, duration and safe failure detail. +It does not expose executable paths or invent warnings for a cache hit. + +The package contains no CLI execution, Action I/O or provider renderer. Factories +project known event fields and validate; callers retain endpoint-specific result +projection and register tokens with their existing masker. + +## Provenance and rollout gate + +Source prepares **0.0.6**, an unpublished candidate. Registry-backed baseline +0.0.5 is published; it lacks these shared helpers and the setup event. Existing +source baselines inspected for this change: + +| Repository | Main inspected | +| ------------------------ | ------------------------------------------ | +| action-kit | `7cc2e8e6af46e7438064cc6205b22f4f7154eb94` | +| notify | `6331ff4a60956a3a2b546e84d737c9338ca46bda` | +| setup-commercekit | `7f650f3162ab027bfd784cf879832f44d3248584` | +| ccv2-build | `923535d589bd8cebece4f3cbf479608f9535c4c3` | +| ccv2-deploy | `bb6d1d6a2ac43a5b6e5b6831c78c5cf28bdb95ca` | +| ccv2-build-cleanup | `9b7ab34515a2fb8ed643f786f754e44e485868d2` | +| action-integration-tests | `e6cefd75d5a926ca2232ac06f332e5b4394a09b5` | + +Candidate consumers must use the exact-source packed tarball only in isolated +private integration workspaces, recording source commits, tarball integrity and +bundle hashes. Normal registry dependencies/lockfiles must never pretend that +0.0.6 is published or contain developer-local links. Consumer readiness remains +**BLOCKED** until a separately authorized publication, exact registry repinning, +normal bundle regeneration and green validation. Publication and source merge +do not authorize release tags, deployment, provider sends or real SAP deletion. + +## Validation + +The additive contract suite passes **394 tests in 12 files** on Node 24.21.0 +and pnpm 12.6.0. Notification-contract coverage is **100% statements, 99.64% +branches, 100% functions and 100% lines**. All existing 95% thresholds remain +unchanged, including the untouched CCv2 client package. Focused tests cover every +new status, confidential-first capture, all-input previews, forged snapshots, +malformed Unicode secrets, schema/type compatibility, private links, Markdown/ +HTML escaping, explicit truncation and the UTF-8 summary cap. + +Packed ESM, JSON schema, declarations and public export boundaries are exercised +with installed temporary consumers. Full quality, public scanning and audit must +pass before the exact-source tarball is retained for final consumer replay. +Candidate consumer totals and exact artifact/source/bundle fingerprints are +recorded separately; WIP artifact success is not final registry readiness. + +Independent privacy review found and reproduced two gaps before publication: +known secrets encoded with lowercase/mixed-case percent hex, and Slack webhook +hosts using a trailing DNS dot. Regression tests now cover raw/URL/form encodings +with literal case preserved, forged cross-field snapshots, and case/encoded-host/ +trailing-dot webhook variants in values and links. The existing shared redactor +and display policy were corrected without changing OAuth or transport. diff --git a/packages/notification-contract/CHANGELOG.md b/packages/notification-contract/CHANGELOG.md index ce64972..f2f3e86 100644 --- a/packages/notification-contract/CHANGELOG.md +++ b/packages/notification-contract/CHANGELOG.md @@ -1,6 +1,18 @@ # notification-contract changelog -## 0.0.5 — unpublished candidate +## 0.0.6 — unpublished candidate + +- Add shared bounded input snapshots, confidential-first capture, safe text + display, status styles and pure GitHub summary formatting. +- Add setup-completed events and optional input/issue context to existing Check, + Build and Deploy events, preserving their outcome semantics. +- Preserve the cleanup schema and provider/OAuth boundaries; handle malformed + Unicode secrets safely in encoded redaction forms. +- Redact mixed-case percent-encoded secret forms and canonical webhook hostname + variants in inputs, summaries and links. +- Registry readiness remains blocked until separately authorized publication. + +## 0.0.5 Allow the existing `warning` status specifically for build cleanup through new public `BuildCleanupStatus`, preserving Build/Deploy `CompletedStatus` semantics. @@ -11,10 +23,10 @@ be literal `[REDACTED]`, public values are at most 256 characters, and at most t safe issues are accepted. Issue factories project only safe code/message fields. Old cleanup events remain valid and existing event meanings are unchanged. -Registry checks found published 0.0.4 and no 0.0.5. This source version is not a -published registry dependency. Consumers require coordinated source/tarball tests -and separately authorized publication before normal exact-version adoption. -No OAuth/transport, provider delivery, package publication or release change. +Published from `7cc2e8e6af46e7438064cc6205b22f4f7154eb94` by the owner-authorized +package-only workflow run 43667757934. Registry integrity matched the previously +tested source artifact. No Git tag, Action release, OAuth/transport change or +provider delivery implementation was introduced. ## 0.0.4 diff --git a/packages/notification-contract/README.md b/packages/notification-contract/README.md index 0f47ed2..fa9f412 100644 --- a/packages/notification-contract/README.md +++ b/packages/notification-contract/README.md @@ -1,6 +1,6 @@ # @commercekit-tools/notification-contract -Provider-neutral NotificationEvent v1 types, schema and validation for Notify, Build, Deploy, future Check and reusable workflows. No CCv2 calls, CLI diagnostics, Action I/O, polling, provider rendering or webhook delivery. +Provider-neutral NotificationEvent v1 types, schema and validation for Notify, Build, Deploy, future Check and reusable workflows. It also provides pure status/summary formatting and bounded input display context. No CCv2 calls, CLI diagnostics, Action I/O, polling, provider rendering or webhook delivery. Public exports: `NotificationEvent`, `NotificationStatus`, `NotificationEventType`, `NotificationSource`, `NotificationContext`, `NotificationLink`, `SafeNotificationError`, `CheckEventData`, `BuildEventData`, `DeploymentEventData`, `BuildCleanupEvent`, `BuildCleanupEventData`, `BuildCleanupStatus`, `BuildCleanupInputSnapshot`; `validateNotificationEvent`, `parseNotificationEventJson`, `validateNotificationLink`, `validateGitHubRunUrl`, `extractSafeGitHubContext`, `mergeProducerContext`, `mergeNotifierContext`, `createCheckEvent`, `createBuildEvent`, `createDeploymentEvent`, `createBuildCleanupEvent`, `redactKnownSecrets`, `sanitizeBoundedText`, `sanitizeLabel`, `sanitizeErrorMessage`. @@ -22,7 +22,12 @@ Extraction reads only approved runner fields, never event payloads. Explicit pro Install from GitHub Packages after a supported version is published. Configure `@commercekit-tools:registry=https://npm.pkg.github.com`; local users need a classic PAT with `read:packages` in user-level configuration. Consumer Actions need package Actions read access and `GITHUB_TOKEN` with `packages: read`. See the repository authentication guide. Pin exact versions, never ranges. -Source version: `0.0.5`, an **unpublished candidate**. Registry inspection verified published versions through `0.0.4`; `0.0.5` was absent. Do not pin `0.0.5` as a registry dependency until separately authorized publication is verified. The currently published `0.0.4` package lacks the additions below. Exact-source packed-tarball tests may validate candidate consumers in isolation; no machine-local link belongs in a normal lockfile. Stable API and coordinated consumer/Sandbox validation remain future gates. Reservation versions are unsupported. +Source version: `0.0.6`, an **unpublished candidate** for consistent ecosystem +reporting. Exact registry checks verified publication through `0.0.5`; that +version lacks the new helpers and setup event. Do not pin `0.0.6` as a registry +dependency until separately authorized publication is verified. Candidate tests +use exact-source packed tarballs in isolated private consumers with integrity +records; normal lockfiles must not contain machine-local links. Independent package SemVer applies; stable breaking changes require a major increment. NotificationEvent schema version is separate from package version. Preserve current consumer contracts during migration. No telemetry or logging is performed. Use synthetic tests and register exact runtime secrets; pattern redaction cannot identify all secrets. @@ -46,13 +51,14 @@ published 0.0.3 or older reject it. Verify 0.0.4 registry availability and the matching Notify revision before enabling this integration; a source manifest is not publication evidence. -## Cleanup warning and input context — 0.0.5 candidate +## Cleanup warning and input context — 0.0.5 This additive cleanup-only extension accepts the existing global `warning` status through public `BuildCleanupStatus`; Build and Deploy continue using unchanged `CompletedStatus`. Old cleanup events remain valid. A published 0.0.4 validator rejects cleanup warnings, so producer and Notify revisions must be coordinated. -No publication, tag, release or consumer migration is performed by these changes. +The 0.0.5 package was subsequently published through authorized package-only +workflow run 43667757934. That publication created no Git tag or Action release. `BuildCleanupEventData` adds optional `inputs`, `inputsValidated`, `inputsTruncated`, `protectedCount`, `blockedCount` and `issues` fields. If an @@ -78,3 +84,20 @@ The factory retains only code/message for each issue, dropping raw nested fields then validates the event. Outcome/severity interpretation belongs to the producer: a warning must not hide a deletion failure or unknown mutation result. Accepted deletion counts still describe initiation rather than physical removal. + +## Consistent Action reporting — 0.0.6 candidate + +New public exports: `SetupEvent`, `SetupEventData`, `NotificationInputContext`, +`NotificationInputData`, `ActionInputDefinition`, `NotificationStyle`, +`ActionSummaryOptions`, `ActionSummarySection`, `createSetupEvent`, +`captureActionInputs`, `createActionInputContext`, `isConfidentialInputName`, +`sanitizeActionText`, `getNotificationStyle` and `renderActionSummary`. + +Build, Deploy and Check data accept optional bounded input snapshots and issues. +Setup uses `commercekit.setup.completed`; completed Build/Deploy semantics are +unchanged. The shared renderer creates a bounded escaped GitHub summary string; +callers perform Action I/O and preserve operation outcomes if reporting fails. + +Read the [format, masking, compatibility and rollout contract](../../docs/action-reporting.md). +The package remains one of the existing two action-kit packages. No provider +payload/delivery or OAuth/transport behavior is added here. diff --git a/packages/notification-contract/package.json b/packages/notification-contract/package.json index b805a44..bbf9812 100644 --- a/packages/notification-contract/package.json +++ b/packages/notification-contract/package.json @@ -1,6 +1,6 @@ { "name": "@commercekit-tools/notification-contract", - "version": "0.0.5", + "version": "0.0.6", "description": "Provider-neutral NotificationEvent v1 contracts and factories.", "license": "MIT", "type": "module", diff --git a/packages/notification-contract/schema/notification-event-v1.schema.json b/packages/notification-contract/schema/notification-event-v1.schema.json index cf10c8a..014db9c 100644 --- a/packages/notification-contract/schema/notification-event-v1.schema.json +++ b/packages/notification-contract/schema/notification-event-v1.schema.json @@ -15,7 +15,8 @@ "ccv2.deployment.started", "ccv2.deployment.action-required", "ccv2.deployment.completed", - "ccv2.build-cleanup.completed" + "ccv2.build-cleanup.completed", + "commercekit.setup.completed" ] }, "status": { @@ -27,7 +28,16 @@ "required": ["product", "component"], "properties": { "product": { "const": "commercekit" }, - "component": { "enum": ["cli-check", "ccv2-build", "ccv2-deploy", "workflow", "ccv2-build-cleanup"] }, + "component": { + "enum": [ + "cli-check", + "ccv2-build", + "ccv2-deploy", + "workflow", + "ccv2-build-cleanup", + "setup-commercekit" + ] + }, "version": { "type": "string", "maxLength": 64 } }, "additionalProperties": true @@ -119,6 +129,15 @@ "data": { "$ref": "#/$defs/cleanup" } } } + }, + { + "if": { "properties": { "eventType": { "const": "commercekit.setup.completed" } } }, + "then": { + "properties": { + "status": { "enum": ["success", "warning", "failure", "cancelled", "internal-error"] }, + "data": { "$ref": "#/$defs/setup" } + } + } } ], "$defs": { @@ -140,7 +159,11 @@ "errors": { "type": "integer", "minimum": 0 }, "warnings": { "type": "integer", "minimum": 0 }, "durationMs": { "type": "integer", "minimum": 0 }, - "error": { "$ref": "#/$defs/error" } + "error": { "$ref": "#/$defs/error" }, + "inputs": { "$ref": "#/$defs/actionInputs" }, + "inputsValidated": { "type": "boolean" }, + "inputsTruncated": { "type": "boolean" }, + "issues": { "type": "array", "maxItems": 3, "items": { "$ref": "#/$defs/error" } } }, "additionalProperties": true }, @@ -155,7 +178,11 @@ "buildStatus": { "type": "string", "maxLength": 100 }, "durationMs": { "type": "integer", "minimum": 0 }, "retryCount": { "type": "integer", "minimum": 0 }, - "error": { "$ref": "#/$defs/error" } + "error": { "$ref": "#/$defs/error" }, + "inputs": { "$ref": "#/$defs/actionInputs" }, + "inputsValidated": { "type": "boolean" }, + "inputsTruncated": { "type": "boolean" }, + "issues": { "type": "array", "maxItems": 3, "items": { "$ref": "#/$defs/error" } } }, "additionalProperties": true }, @@ -172,7 +199,11 @@ "deploymentStatus": { "type": "string", "maxLength": 100 }, "durationMs": { "type": "integer", "minimum": 0 }, "decisionType": { "type": "string", "maxLength": 100 }, - "error": { "$ref": "#/$defs/error" } + "error": { "$ref": "#/$defs/error" }, + "inputs": { "$ref": "#/$defs/actionInputs" }, + "inputsValidated": { "type": "boolean" }, + "inputsTruncated": { "type": "boolean" }, + "issues": { "type": "array", "maxItems": 3, "items": { "$ref": "#/$defs/error" } } }, "additionalProperties": true }, @@ -255,6 +286,35 @@ "continue-on-delete-error": { "type": "string", "maxLength": 256 } }, "additionalProperties": false + }, + "actionInputs": { + "type": "object", + "maxProperties": 50, + "propertyNames": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }, + "additionalProperties": { "type": "string", "maxLength": 256 }, + "patternProperties": { + "(?:token|secret|password|credential|webhook|authorization|private-key|api-key)": { + "const": "[REDACTED]" + }, + "^(?:client-id|resource)$": { "const": "[REDACTED]" } + } + }, + "setup": { + "type": "object", + "properties": { + "inputs": { "$ref": "#/$defs/actionInputs" }, + "inputsValidated": { "type": "boolean" }, + "inputsTruncated": { "type": "boolean" }, + "issues": { "type": "array", "maxItems": 3, "items": { "$ref": "#/$defs/error" } }, + "requestedVersion": { "type": "string", "maxLength": 256 }, + "version": { "type": "string", "maxLength": 128 }, + "target": { "type": "string", "maxLength": 128 }, + "buildId": { "type": "string", "maxLength": 128 }, + "cacheHit": { "type": "boolean" }, + "durationMs": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, + "error": { "$ref": "#/$defs/error" } + }, + "additionalProperties": true } }, "additionalProperties": true diff --git a/packages/notification-contract/src/factories.ts b/packages/notification-contract/src/factories.ts index 790ec1b..38f446c 100644 --- a/packages/notification-contract/src/factories.ts +++ b/packages/notification-contract/src/factories.ts @@ -3,6 +3,8 @@ import { schema } from './schema.js'; import { validateNotificationEvent } from './validate.js'; import { extractSafeGitHubContext, mergeProducerContext } from './context.js'; import type { + SetupEvent, + SetupEventData, BuildCleanupEvent, BuildCleanupEventData, NotificationContext, @@ -24,7 +26,7 @@ interface Options { links?: NotificationLink[]; } function create( - kind: 'check' | 'build' | 'deployment' | 'cleanup', + kind: 'check' | 'build' | 'deployment' | 'cleanup' | 'setup', status: string, data: object, options: Options, @@ -46,11 +48,13 @@ function create( }); } const eventType = - kind === 'cleanup' - ? 'ccv2.build-cleanup.completed' - : kind === 'check' - ? 'check.completed' - : `ccv2.${kind}.${status === 'started' ? 'started' : status === 'action-required' ? 'action-required' : 'completed'}`; + kind === 'setup' + ? 'commercekit.setup.completed' + : kind === 'cleanup' + ? 'ccv2.build-cleanup.completed' + : kind === 'check' + ? 'check.completed' + : `ccv2.${kind}.${status === 'started' ? 'started' : status === 'action-required' ? 'action-required' : 'completed'}`; return validateNotificationEvent({ schemaVersion: 1, eventId: options.eventId ?? `ckevt_${randomUUID()}`, @@ -66,11 +70,13 @@ function create( : { product: 'commercekit', component: - kind === 'cleanup' - ? 'ccv2-build-cleanup' - : kind === 'check' - ? 'cli-check' - : `ccv2-${kind === 'deployment' ? 'deploy' : 'build'}`, + kind === 'setup' + ? 'setup-commercekit' + : kind === 'cleanup' + ? 'ccv2-build-cleanup' + : kind === 'check' + ? 'cli-check' + : `ccv2-${kind === 'deployment' ? 'deploy' : 'build'}`, }, context: mergeProducerContext(options.context ?? {}, extractSafeGitHubContext(options.env ?? {})), data: selected, @@ -96,3 +102,9 @@ export function createBuildCleanupEvent( ): BuildCleanupEvent { return create('cleanup', input.status, input.data, input) as BuildCleanupEvent; } + +export function createSetupEvent( + input: Options & { status: SetupEvent['status']; data: SetupEventData }, +): SetupEvent { + return create('setup', input.status, input.data, input) as SetupEvent; +} diff --git a/packages/notification-contract/src/index.ts b/packages/notification-contract/src/index.ts index e1f0680..a71691b 100644 --- a/packages/notification-contract/src/index.ts +++ b/packages/notification-contract/src/index.ts @@ -4,3 +4,6 @@ export * from './links.js'; export * from './context.js'; export * from './sanitize.js'; export * from './factories.js'; +export * from './input-context.js'; +export * from './reporting-style.js'; +export * from './summary.js'; diff --git a/packages/notification-contract/src/input-context.ts b/packages/notification-contract/src/input-context.ts new file mode 100644 index 0000000..e11b152 --- /dev/null +++ b/packages/notification-contract/src/input-context.ts @@ -0,0 +1,137 @@ +import { redactJwtLikeTokens, redactKnownSecrets, sanitizeBoundedText } from './sanitize.js'; +import type { NotificationInputContext } from './types.js'; + +export interface ActionInputDefinition { + name: string; + confidential?: boolean; + display?: 'text' | 'presence' | 'url-origin'; +} +const omittedNames = new Set([ + 'event-json', + 'event-file', + 'traffic-split-json', + 'decision-reason', + 'cancel-reason', +]); +export function isConfidentialInputName(name: string): boolean { + return ( + /token|secret|password|credential|webhook|authorization|private[-_]?key|api[-_]?key/i.test(name) || + ['client-id', 'resource'].includes(name.toLowerCase().replaceAll('_', '-')) + ); +} +function definitionsChecked(definitions: readonly ActionInputDefinition[]): void { + if ( + definitions.length > 50 || + new Set(definitions.map(({ name }) => name)).size !== definitions.length || + definitions.some(({ name }) => !/^[a-z][a-z0-9-]{0,63}$/.test(name)) + ) + throw new Error('Invalid action input definitions.'); +} +function canonicalHostname(hostname: string): string { + let end = hostname.length; + while (hostname.charCodeAt(end - 1) === 46) end--; + return hostname.slice(0, end); +} +/** Redact before bounding. Query-bearing/credential URLs and webhooks are never display text. */ +export function sanitizeActionText(value: string, maxLength = 256, secrets: readonly string[] = []): string { + return sanitizeBoundedText( + redactJwtLikeTokens(redactKnownSecrets(value, secrets)).replace(/https?:\/\/[^\s<>"']+/gi, (text) => { + try { + const url = new URL(text); + return url.username || + url.password || + url.search || + url.hash || + /^hooks\.slack(?:-gov)?\.com$/i.test(canonicalHostname(url.hostname)) + ? '[REDACTED URL]' + : text; + } catch { + return '[REDACTED URL]'; + } + }), + maxLength, + secrets, + ); +} +/** Read and mask confidential inputs before reading any public setting. Values stay in memory. */ +export function captureActionInputs( + definitions: readonly ActionInputDefinition[], + getInput: (name: string) => string, + registerSecret: (value: string) => void, +): Record { + definitionsChecked(definitions); + const confidential = (definition: ActionInputDefinition) => + definition.confidential || isConfidentialInputName(definition.name); + const values = new Map(); + for (const definition of [ + ...definitions.filter(confidential), + ...definitions.filter((d) => !confidential(d)), + ]) { + const value = getInput(definition.name); + if (confidential(definition) && value) registerSecret(value); + values.set(definition.name, value); + } + return Object.fromEntries(definitions.map(({ name }) => [name, values.get(name)!])); +} +export function createActionInputContext( + definitions: readonly ActionInputDefinition[], + raw: Readonly>, + options: { + validated: boolean; + effective?: Readonly>; + secrets?: readonly string[]; + sanitize?: (value: string, max: number) => string; + }, +): NotificationInputContext { + definitionsChecked(definitions); + const secrets = [ + ...(options.secrets ?? []), + ...definitions + .filter((d) => d.confidential || isConfidentialInputName(d.name)) + .map((d) => raw[d.name] ?? ''), + ]; + let truncated = false; + const inputs = Object.fromEntries( + definitions.map((definition) => { + const { name } = definition; + if (definition.confidential || isConfidentialInputName(name)) return [name, '[REDACTED]']; + const original = + options.validated && Object.hasOwn(options.effective ?? {}, name) + ? options.effective![name]! + : (raw[name] ?? ''); + if (definition.display === 'presence' || omittedNames.has(name)) + return [name, original ? '[PROVIDED: VALUE OMITTED]' : '(not set)']; + let value = original || '(not set)'; + if (definition.display === 'url-origin' && original) { + try { + const url = new URL(original); + value = + url.protocol === 'https:' && + !url.username && + !url.password && + !url.search && + !url.hash && + url.pathname === '/' + ? url.origin + : '[INVALID OR PRIVATE URL]'; + } catch { + value = '[INVALID OR PRIVATE URL]'; + } + } + value = sanitizeActionText(value, Number.MAX_SAFE_INTEGER, secrets); + if (options.sanitize) + value = sanitizeActionText( + options.sanitize(value, Number.MAX_SAFE_INTEGER), + Number.MAX_SAFE_INTEGER, + secrets, + ); + const chars = [...value]; + if (chars.length > 256) { + value = `${chars.slice(0, 242).join('')}… [truncated]`; + truncated = true; + } + return [name, value]; + }), + ); + return { inputs, inputsValidated: options.validated, inputsTruncated: truncated }; +} diff --git a/packages/notification-contract/src/reporting-style.ts b/packages/notification-contract/src/reporting-style.ts new file mode 100644 index 0000000..5561a54 --- /dev/null +++ b/packages/notification-contract/src/reporting-style.ts @@ -0,0 +1,22 @@ +import type { NotificationStatus } from './types.js'; + +/** Provider-neutral visual semantics shared by cards and GitHub run summaries. */ +export interface NotificationStyle { + label: string; + icon: string; + tone: 'info' | 'success' | 'warning' | 'danger' | 'neutral'; + accent: string; +} +const styles: Readonly>> = { + started: { label: 'Started', icon: 'ℹ️', tone: 'info', accent: '#1877C9' }, + success: { label: 'Succeeded', icon: '✅', tone: 'success', accent: '#168A45' }, + warning: { label: 'Completed with warnings', icon: '⚠️', tone: 'warning', accent: '#B7791F' }, + failure: { label: 'Failed', icon: '❌', tone: 'danger', accent: '#C62828' }, + 'internal-error': { label: 'CommerceKit error', icon: '❌', tone: 'danger', accent: '#C62828' }, + cancelled: { label: 'Cancelled', icon: '⏹️', tone: 'neutral', accent: '#6B7280' }, + 'action-required': { label: 'Action required', icon: '⚠️', tone: 'warning', accent: '#B7791F' }, +}; +export function getNotificationStyle(status: NotificationStatus): Readonly { + if (!Object.hasOwn(styles, status)) throw new Error('Invalid notification status.'); + return Object.freeze({ ...styles[status] }); +} diff --git a/packages/notification-contract/src/sanitize.ts b/packages/notification-contract/src/sanitize.ts index 5eef7c6..0333f26 100644 --- a/packages/notification-contract/src/sanitize.ts +++ b/packages/notification-contract/src/sanitize.ts @@ -46,14 +46,30 @@ export function redactJwtLikeTokens(value: string): string { return output + value.slice(consumed); } +/** Percent hex is case-insensitive; other characters in an encoded secret are not. */ +function redactEncodedSecret(value: string, form: string): string { + const normalize = (text: string) => text.replace(/%[a-f0-9]{2}/gi, (escape) => escape.toUpperCase()); + const haystack = normalize(value); + const needle = normalize(form); + let cursor = 0; + let output = ''; + let index = haystack.indexOf(needle, cursor); + while (index !== -1) { + output += value.slice(cursor, index) + '[REDACTED]'; + cursor = index + form.length; + index = haystack.indexOf(needle, cursor); + } + return output + value.slice(cursor); +} + export function redactKnownSecrets(value: string, secrets: readonly string[] = []): string { for (const secret of [...secrets].filter(Boolean).sort((a, b) => b.length - a.length)) { for (const form of new Set([ secret, - encodeURIComponent(secret), + encodeURIComponent(new URLSearchParams({ v: secret }).get('v')!), new URLSearchParams({ v: secret }).toString().slice(2), ])) - value = value.split(form).join('[REDACTED]'); + value = form.includes('%') ? redactEncodedSecret(value, form) : value.split(form).join('[REDACTED]'); } return value .replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]') diff --git a/packages/notification-contract/src/schema.ts b/packages/notification-contract/src/schema.ts index 1516255..a09054e 100644 --- a/packages/notification-contract/src/schema.ts +++ b/packages/notification-contract/src/schema.ts @@ -17,6 +17,7 @@ export const schema = { 'ccv2.deployment.action-required', 'ccv2.deployment.completed', 'ccv2.build-cleanup.completed', + 'commercekit.setup.completed', ], }, status: { @@ -28,7 +29,16 @@ export const schema = { required: ['product', 'component'], properties: { product: { const: 'commercekit' }, - component: { enum: ['cli-check', 'ccv2-build', 'ccv2-deploy', 'workflow', 'ccv2-build-cleanup'] }, + component: { + enum: [ + 'cli-check', + 'ccv2-build', + 'ccv2-deploy', + 'workflow', + 'ccv2-build-cleanup', + 'setup-commercekit', + ], + }, version: { type: 'string', maxLength: 64 }, }, additionalProperties: true, @@ -119,6 +129,15 @@ export const schema = { }, }, }, + { + if: { properties: { eventType: { const: 'commercekit.setup.completed' } } }, + then: { + properties: { + status: { enum: ['success', 'warning', 'failure', 'cancelled', 'internal-error'] }, + data: { $ref: '#/$defs/setup' }, + }, + }, + }, ], $defs: { error: { @@ -140,6 +159,10 @@ export const schema = { warnings: { type: 'integer', minimum: 0 }, durationMs: { type: 'integer', minimum: 0 }, error: { $ref: '#/$defs/error' }, + inputs: { $ref: '#/$defs/actionInputs' }, + inputsValidated: { type: 'boolean' }, + inputsTruncated: { type: 'boolean' }, + issues: { type: 'array', maxItems: 3, items: { $ref: '#/$defs/error' } }, }, additionalProperties: true, }, @@ -155,6 +178,10 @@ export const schema = { durationMs: { type: 'integer', minimum: 0 }, retryCount: { type: 'integer', minimum: 0 }, error: { $ref: '#/$defs/error' }, + inputs: { $ref: '#/$defs/actionInputs' }, + inputsValidated: { type: 'boolean' }, + inputsTruncated: { type: 'boolean' }, + issues: { type: 'array', maxItems: 3, items: { $ref: '#/$defs/error' } }, }, additionalProperties: true, }, @@ -172,6 +199,10 @@ export const schema = { durationMs: { type: 'integer', minimum: 0 }, decisionType: { type: 'string', maxLength: 100 }, error: { $ref: '#/$defs/error' }, + inputs: { $ref: '#/$defs/actionInputs' }, + inputsValidated: { type: 'boolean' }, + inputsTruncated: { type: 'boolean' }, + issues: { type: 'array', maxItems: 3, items: { $ref: '#/$defs/error' } }, }, additionalProperties: true, }, @@ -255,6 +286,35 @@ export const schema = { }, additionalProperties: false, }, + actionInputs: { + type: 'object', + maxProperties: 50, + propertyNames: { type: 'string', pattern: '^[a-z][a-z0-9-]{0,63}$' }, + additionalProperties: { type: 'string', maxLength: 256 }, + patternProperties: { + '(?:token|secret|password|credential|webhook|authorization|private-key|api-key)': { + const: '[REDACTED]', + }, + '^(?:client-id|resource)$': { const: '[REDACTED]' }, + }, + }, + setup: { + type: 'object', + properties: { + inputs: { $ref: '#/$defs/actionInputs' }, + inputsValidated: { type: 'boolean' }, + inputsTruncated: { type: 'boolean' }, + issues: { type: 'array', maxItems: 3, items: { $ref: '#/$defs/error' } }, + requestedVersion: { type: 'string', maxLength: 256 }, + version: { type: 'string', maxLength: 128 }, + target: { type: 'string', maxLength: 128 }, + buildId: { type: 'string', maxLength: 128 }, + cacheHit: { type: 'boolean' }, + durationMs: { type: 'integer', minimum: 0, maximum: 9007199254740991 }, + error: { $ref: '#/$defs/error' }, + }, + additionalProperties: true, + }, }, additionalProperties: true, } as const; diff --git a/packages/notification-contract/src/summary.ts b/packages/notification-contract/src/summary.ts new file mode 100644 index 0000000..9aef052 --- /dev/null +++ b/packages/notification-contract/src/summary.ts @@ -0,0 +1,151 @@ +import { validateNotificationLink } from './links.js'; +import { isConfidentialInputName, sanitizeActionText } from './input-context.js'; +import { redactJwtLikeTokens, redactKnownSecrets } from './sanitize.js'; +import { getNotificationStyle } from './reporting-style.js'; +import type { + NotificationInputContext, + NotificationLink, + NotificationStatus, + SafeNotificationError, +} from './types.js'; + +type Cell = string | number | boolean; +export interface ActionSummarySection { + title: string; + headers: readonly string[]; + rows: readonly (readonly Cell[])[]; +} +export interface ActionSummaryOptions { + title: string; + status: NotificationStatus; + description?: string; + facts?: readonly { label: string; value: Cell }[]; + inputContext?: NotificationInputContext; + error?: SafeNotificationError; + issues?: readonly SafeNotificationError[]; + sections?: readonly ActionSummarySection[]; + links?: readonly NotificationLink[]; + secrets?: readonly string[]; +} +/** Pure bounded Markdown. This function writes no files and never changes an Action outcome. */ +export function renderActionSummary(options: ActionSummaryOptions): string { + const secrets = [ + ...(options.secrets ?? []), + ...Object.entries(options.inputContext?.inputs ?? {}) + .filter(([name]) => isConfidentialInputName(name)) + .map(([, value]) => (value === '[REDACTED]' ? '' : value)), + ]; + let limited = false; + const text = (value: Cell, max = 256) => { + const characters = [...sanitizeActionText(String(value), Number.MAX_SAFE_INTEGER, secrets)]; + if (characters.length > max) limited = true; + const bounded = + characters.length > max + ? `${characters.slice(0, max - 13).join('')}… [truncated]` + : characters.join(''); + return bounded + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/([\\`*_[\]{}()|!#~])/g, '\\$1') + .replace(/@/g, '@') + .replace(/[\r\n\u2028\u2029]/g, ' '); + }; + const style = getNotificationStyle(options.status); + const lines = [`## ${style.icon} ${text(options.title, 200)} — ${style.label}`, '']; + const table = ( + title: string, + headers: readonly string[], + rows: readonly (readonly Cell[])[], + maximum = 50, + ) => { + lines.push( + `### ${text(title, 100)}`, + '', + `| ${headers + .slice(0, 8) + .map((h) => text(h, 80)) + .join(' | ')} |`, + `| ${headers + .slice(0, 8) + .map(() => '---') + .join(' | ')} |`, + ); + for (const row of rows.slice(0, maximum)) + lines.push( + `| ${row + .slice(0, 8) + .map((value) => text(value)) + .join(' | ')} |`, + ); + lines.push(''); + if (rows.length > maximum || headers.length > 8 || rows.some((row) => row.length > 8)) limited = true; + }; + if (options.description) lines.push(text(options.description, 1000), ''); + if (options.facts?.length) + table( + 'Result', + ['Detail', 'Value'], + options.facts.map(({ label, value }) => [label, value]), + ); + if (options.inputContext) { + const context = options.inputContext; + table( + 'Inputs', + ['Input', 'Value'], + Object.entries(context.inputs).map(([name, value]) => [ + name, + isConfidentialInputName(name) ? '[REDACTED]' : value, + ]), + ); + lines.push(`Validated: ${context.inputsValidated}; truncated: ${context.inputsTruncated}.`, ''); + if (!context.inputsValidated) + lines.push('⚠️ Input validation did not complete; values reflect safely captured settings.', ''); + if (context.inputsTruncated) lines.push('⚠️ Some input values were truncated before reporting.', ''); + } + const issues = [...(options.error ? [options.error] : []), ...(options.issues ?? [])]; + if (issues.length) + table( + 'Issues', + ['Code', 'Detail'], + issues.map((issue) => [issue.code ?? 'unreported', issue.message]), + 4, + ); + for (const section of (options.sections ?? []).slice(0, 8)) + table(section.title, section.headers, section.rows); + if ((options.sections?.length ?? 0) > 8) limited = true; + const links: string[] = []; + if ((options.links?.length ?? 0) > 3) limited = true; + for (const link of (options.links ?? []).slice(0, 3)) { + try { + const url = validateNotificationLink(link.url); + if ( + [...url.searchParams.entries()].some( + ([key, value]) => + isConfidentialInputName(key) || + /^(sig|signature|code|key|auth)$/i.test(key) || + sanitizeActionText(value, Number.MAX_SAFE_INTEGER, secrets) !== value, + ) || + redactJwtLikeTokens(redactKnownSecrets(link.url, secrets)) !== link.url || + sanitizeActionText(url.origin, 2048) !== url.origin + ) + continue; + links.push(`[${text(link.label, 80)}](${url.href.replace(/\(/g, '%28').replace(/\)/g, '%29')})`); + } catch { + // Unsafe links are omitted, never included as raw error text. + } + } + if (links.length) lines.push('### Workflow', '', links.join(' · '), ''); + // Keep complete lines/table rows, including under multibyte input, with explicit omission metadata. + const maximumBytes = 64 * 1024 - 256; + let output = ''; + for (const line of lines) { + if (new TextEncoder().encode(output + line + '\n').length > maximumBytes) { + limited = true; + break; + } + output += `${line}\n`; + } + if (limited) output += '\n⚠️ Some details were omitted due to summary limits.\n'; + return output; +} diff --git a/packages/notification-contract/src/types.ts b/packages/notification-contract/src/types.ts index 70544f0..46b393d 100644 --- a/packages/notification-contract/src/types.ts +++ b/packages/notification-contract/src/types.ts @@ -4,6 +4,7 @@ export type CompletedStatus = 'success' | 'failure' | 'cancelled' | 'internal-er /** Cleanup warning support does not change Build or Deploy completed semantics. */ export type BuildCleanupStatus = CompletedStatus | 'warning'; export type NotificationEventType = + | 'commercekit.setup.completed' | 'ccv2.build-cleanup.completed' | 'check.completed' | 'ccv2.build.started' @@ -13,7 +14,8 @@ export type NotificationEventType = | 'ccv2.deployment.action-required'; export interface NotificationSource { product: 'commercekit'; - component: 'cli-check' | 'ccv2-build' | 'ccv2-deploy' | 'ccv2-build-cleanup' | 'workflow'; + component: + 'cli-check' | 'ccv2-build' | 'ccv2-deploy' | 'ccv2-build-cleanup' | 'setup-commercekit' | 'workflow'; version?: string; } export interface NotificationContext { @@ -37,7 +39,33 @@ export interface SafeNotificationError { code?: string; message: string; } -export interface CheckEventData { +/** Display-only context. Producers include every configured input name and redact secrets. */ +export interface NotificationInputContext { + inputs: Readonly>; + inputsValidated: boolean; + inputsTruncated: boolean; +} +export interface NotificationInputData { + inputs?: Readonly>; + inputsValidated?: boolean; + inputsTruncated?: boolean; + issues?: SafeNotificationError[]; +} +export interface SetupEventData extends NotificationInputData { + requestedVersion?: string; + version?: string; + target?: string; + buildId?: string; + cacheHit?: boolean; + durationMs?: number; + error?: SafeNotificationError; +} +export type SetupEvent = EventBase & { + eventType: 'commercekit.setup.completed'; + status: CompletedStatus | 'warning'; + data: SetupEventData; +}; +export interface CheckEventData extends NotificationInputData { mode?: 'full' | 'changed'; changedFrom?: string; filesChecked?: number; @@ -46,7 +74,7 @@ export interface CheckEventData { durationMs?: number; error?: SafeNotificationError; } -export interface BuildEventData { +export interface BuildEventData extends NotificationInputData { subscriptionCode?: string; applicationCode?: string; buildCode?: string; @@ -57,7 +85,7 @@ export interface BuildEventData { retryCount?: number; error?: SafeNotificationError; } -export interface DeploymentEventData { +export interface DeploymentEventData extends NotificationInputData { subscriptionCode?: string; applicationCode?: string; deploymentCode?: string; @@ -97,7 +125,7 @@ export type DeploymentEvent = EventBase & | { eventType: 'ccv2.deployment.completed'; status: CompletedStatus } | { eventType: 'ccv2.deployment.action-required'; status: 'action-required' } ) & { data: DeploymentEventData }; -export type NotificationEvent = CheckEvent | BuildEvent | DeploymentEvent | BuildCleanupEvent; +export type NotificationEvent = CheckEvent | BuildEvent | DeploymentEvent | BuildCleanupEvent | SetupEvent; /** Bounded display context only; authentication values must never enter an event. */ export interface BuildCleanupInputSnapshot { diff --git a/packages/notification-contract/tests/input-context.test.ts b/packages/notification-contract/tests/input-context.test.ts new file mode 100644 index 0000000..1866a9e --- /dev/null +++ b/packages/notification-contract/tests/input-context.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest'; +import { + captureActionInputs, + createActionInputContext, + isConfidentialInputName, + sanitizeActionText, + redactKnownSecrets, + type ActionInputDefinition, +} from '../src/index.js'; + +const definitions: readonly ActionInputDefinition[] = [ + { name: 'version' }, + { name: 'client-secret' }, + { name: 'client-id' }, + { name: 'resource' }, + { name: 'token-endpoint' }, + { name: 'api-base-url', display: 'url-origin' }, + { name: 'custom', confidential: true }, + { name: 'payload', display: 'presence' }, +]; +describe('shared action input context', () => { + it('captures each name once, masking each secret before public values are read', () => { + const operations: string[] = []; + const raw = captureActionInputs( + definitions, + (name) => { + operations.push(`read:${name}`); + return name === 'resource' ? '' : name; + }, + (value) => operations.push(`mask:${value}`), + ); + expect(Object.keys(raw)).toEqual(definitions.map(({ name }) => name)); + expect(operations).toEqual([ + 'read:client-secret', + 'mask:client-secret', + 'read:client-id', + 'mask:client-id', + 'read:resource', + 'read:token-endpoint', + 'mask:token-endpoint', + 'read:custom', + 'mask:custom', + 'read:version', + 'read:api-base-url', + 'read:payload', + ]); + }); + it('permits no inputs and propagates input-read failures before publishing anything', () => { + expect( + captureActionInputs( + [], + () => '', + () => {}, + ), + ).toEqual({}); + expect(() => + captureActionInputs( + definitions, + () => { + throw Error('read failed'); + }, + () => {}, + ), + ).toThrow('read failed'); + }); + for (const invalid of [ + [{ name: 'bad_name' }], + [{ name: 'version' }, { name: 'version' }], + Array.from({ length: 51 }, (_, n) => ({ name: `input-${n}` })), + ]) + it('rejects invalid or unbounded definitions', () => { + expect(() => + captureActionInputs( + invalid, + () => '', + () => {}, + ), + ).toThrow('Invalid action input definitions'); + expect(() => createActionInputContext(invalid, {}, { validated: false })).toThrow( + 'Invalid action input definitions', + ); + }); + for (const name of [ + 'client-id', + 'CLIENT_ID', + 'resource', + 'token-endpoint', + 'db-password', + 'webhook-url', + 'api-key', + 'private_key', + 'credential', + ]) + it(`recognizes confidential ${name}`, () => expect(isConfidentialInputName(name)).toBe(true)); + it('keeps public names public', () => expect(isConfidentialInputName('timeout-minutes')).toBe(false)); + it('reports every setting, normalized defaults and forced authentication placeholders', () => { + const context = createActionInputContext( + definitions, + { version: '', custom: 'private', payload: 'secret data' }, + { validated: true, effective: { version: 'latest', 'api-base-url': 'https://public.example.test/' } }, + ); + expect(context).toEqual({ + inputs: { + version: 'latest', + 'client-secret': '[REDACTED]', + 'client-id': '[REDACTED]', + resource: '[REDACTED]', + 'token-endpoint': '[REDACTED]', + 'api-base-url': 'https://public.example.test', + custom: '[REDACTED]', + payload: '[PROVIDED: VALUE OMITTED]', + }, + inputsValidated: true, + inputsTruncated: false, + }); + }); + it('does not substitute effective configuration for a rejected input', () => { + expect( + createActionInputContext( + [{ name: 'version' }, { name: 'blank' }], + { version: 'invalid' }, + { validated: false, effective: { version: 'latest' } }, + ), + ).toEqual({ + inputs: { version: 'invalid', blank: '(not set)' }, + inputsValidated: false, + inputsTruncated: false, + }); + const inherited = Object.create({ version: 'inherited' }); + expect( + createActionInputContext( + [{ name: 'version' }], + { version: 'raw' }, + { validated: true, effective: inherited }, + ).inputs.version, + ).toBe('raw'); + }); + it('redacts confidential and received tokens in other values, including sanitizer output', () => { + const context = createActionInputContext( + definitions, + { + 'client-secret': 'private marker', + version: 'private marker|private%20marker|private+marker|received-token', + }, + { + validated: true, + secrets: ['received-token'], + sanitize: (value, max) => { + expect(max).toBe(Number.MAX_SAFE_INTEGER); + return `${value} private marker received-token`; + }, + }, + ); + expect(JSON.stringify(context)).not.toContain('private marker'); + expect(JSON.stringify(context)).not.toContain('private%20marker'); + expect(JSON.stringify(context)).not.toContain('private+marker'); + expect(JSON.stringify(context)).not.toContain('received-token'); + expect(context.inputs.version).toContain('[REDACTED]'); + }); + it('handles malformed Unicode credentials without abandoning redaction', () => { + const secret = '\ud800private'; + expect(redactKnownSecrets(`${secret} %EF%BF%BDprivate`, [secret])).toBe('[REDACTED] [REDACTED]'); + expect( + createActionInputContext( + definitions, + { 'client-secret': secret, version: secret }, + { validated: false }, + ).inputs.version, + ).toBe('[REDACTED]'); + }); + it('redacts URL and form secrets with upper, lower and mixed percent hex without folding literal text', () => { + const secret = 'Synthetic/Private+Value €'; + const url = encodeURIComponent(secret); + const form = new URLSearchParams({ value: secret }).toString().slice(6); + const lower = url.replace(/%[0-9A-F]{2}/g, (escape) => escape.toLowerCase()); + const mixed = form.replace(/%[0-9A-F]{2}/g, (escape, index: number) => + index % 2 ? escape.toLowerCase() : escape, + ); + const value = `${url}|${lower}|${mixed}|${form}|${lower}`; + expect(redactKnownSecrets(value, [secret])).toBe(Array(5).fill('[REDACTED]').join('|')); + expect(redactKnownSecrets(lower.replace('Synthetic', 'synthetic'), [secret])).toBe( + lower.replace('Synthetic', 'synthetic'), + ); + const context = createActionInputContext( + [{ name: 'client-secret' }, { name: 'branch' }], + { 'client-secret': secret, branch: value }, + { validated: false }, + ); + expect(context.inputs.branch).toBe(Array(5).fill('[REDACTED]').join('|')); + expect(sanitizeActionText(value, 256, [secret])).toBe(Array(5).fill('[REDACTED]').join('|')); + expect(redactKnownSecrets('100% private', ['100% private'])).toBe('[REDACTED]'); + }); + it('bounds after redaction and marks multibyte previews explicitly', () => { + const context = createActionInputContext( + [{ name: 'version' }], + { version: '✅'.repeat(257) }, + { validated: true }, + ); + expect([...context.inputs.version!].length).toBeLessThanOrEqual(256); + expect(context.inputs.version).toMatch(/… \[truncated\]$/); + expect(context.inputsTruncated).toBe(true); + expect( + createActionInputContext([{ name: 'version' }], { version: 'a'.repeat(256) }, { validated: true }) + .inputsTruncated, + ).toBe(false); + }); + for (const name of ['event-json', 'event-file', 'traffic-split-json', 'decision-reason', 'cancel-reason']) + it(`omits free-form ${name} values by policy`, () => { + expect( + createActionInputContext([{ name }], { [name]: 'unclassified secret' }, { validated: false }).inputs[ + name + ], + ).toBe('[PROVIDED: VALUE OMITTED]'); + expect(createActionInputContext([{ name }], {}, { validated: true }).inputs[name]).toBe('(not set)'); + }); + for (const url of [ + 'http://public.example.test', + 'https://user:pass@public.example.test', + 'https://public.example.test?key=private', + 'https://public.example.test#private', + 'https://public.example.test/path', + 'not-a-url', + ]) + it(`does not echo invalid/private API origins: ${url}`, () => + expect( + createActionInputContext( + [{ name: 'api-base-url', display: 'url-origin' }], + { 'api-base-url': url }, + { validated: false }, + ).inputs['api-base-url'], + ).toBe('[INVALID OR PRIVATE URL]')); + it('reports a blank optional origin safely', () => + expect( + createActionInputContext([{ name: 'api-base-url', display: 'url-origin' }], {}, { validated: false }) + .inputs['api-base-url'], + ).toBe('(not set)')); + for (const value of [ + 'https://user:pass@example.test/path', + 'https://example.test?private=value', + 'https://example.test/#private', + 'https://' + 'hooks.slack.com/services/opaque/value', + 'https://hooks.slack-gov.com/services/opaque/value', + 'https://' + 'hooks.slack.com./services/synthetic/private', + 'https://' + 'HOOKS.SLACK.COM./services/synthetic/private', + 'https://' + '%68ooks.slack.com./services/synthetic/private', + 'https://' + 'hooks.slack-gov.com./services/synthetic/private', + 'https://%', + ]) + it(`redacts private URL-shaped display text: ${value}`, () => + expect(sanitizeActionText(value)).toBe('[REDACTED URL]')); + it('preserves ordinary public origins and removes known credential shapes', () => { + expect(sanitizeActionText('https://public.example.test')).toBe('https://public.example.test'); + expect( + sanitizeActionText('eyJheader.payload.signature ghp_privateMarker Bearer opaque\u0000'), + ).not.toContain('privateMarker'); + expect(sanitizeActionText('eyJheader.payload.signature')).toBe('[REDACTED]'); + expect(() => sanitizeActionText('public', -1)).toThrow('Invalid text limit'); + }); +}); diff --git a/packages/notification-contract/tests/reporting.test.ts b/packages/notification-contract/tests/reporting.test.ts new file mode 100644 index 0000000..bc98cf2 --- /dev/null +++ b/packages/notification-contract/tests/reporting.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest'; +import { getNotificationStyle, renderActionSummary, type NotificationStatus } from '../src/index.js'; + +const states = [ + ['success', '✅', 'success', 'Succeeded'], + ['failure', '❌', 'danger', 'Failed'], + ['warning', '⚠️', 'warning', 'Completed with warnings'], + ['started', 'ℹ️', 'info', 'Started'], + ['cancelled', '⏹️', 'neutral', 'Cancelled'], + ['internal-error', '❌', 'danger', 'CommerceKit error'], + ['action-required', '⚠️', 'warning', 'Action required'], +] as const; +describe('common status and summary presentation', () => { + for (const [status, icon, tone, label] of states) + it(`shares the ${status} display contract`, () => { + expect(getNotificationStyle(status)).toMatchObject({ icon, tone, label }); + expect(Object.isFrozen(getNotificationStyle(status))).toBe(true); + expect(renderActionSummary({ title: 'CommerceKit · Action', status })).toBe( + `## ${icon} CommerceKit · Action — ${label}\n\n`, + ); + }); + it('rejects invented and prototype statuses', () => { + for (const status of ['unknown', 'constructor', '__proto__']) + expect(() => getNotificationStyle(status as NotificationStatus)).toThrow('Invalid notification status'); + }); + it('provides ordered result, input, issue, detail and workflow sections', () => { + const summary = renderActionSummary({ + title: 'CommerceKit · Build', + status: 'failure', + description: 'Build could not complete.', + facts: [ + { label: 'Build code', value: 'build-1' }, + { label: 'Retries', value: 0 }, + { label: 'Wait', value: false }, + ], + inputContext: { + inputs: { branch: 'main', 'client-secret': '[REDACTED]' }, + inputsValidated: true, + inputsTruncated: false, + }, + error: { code: 'authorization-failure', message: 'Authorization failed. HTTP 403.' }, + issues: [{ message: 'Review the configured permissions.' }], + sections: [{ title: 'Safety', headers: ['Check', 'State'], rows: [['Inventory', true]] }], + links: [{ label: 'Workflow run', url: 'https://github.example/org/repo/actions/runs/42' }], + }); + expect(summary).toContain('| Retries | 0 |'); + expect(summary).toContain('| Wait | false |'); + expect(summary).toContain('| authorization-failure | Authorization failed. HTTP 403. |'); + expect(summary).toContain('| unreported | Review the configured permissions. |'); + expect(summary).toContain('Validated: true; truncated: false.'); + expect(summary.match(/^### .+$/gm)).toEqual([ + '### Result', + '### Inputs', + '### Issues', + '### Safety', + '### Workflow', + ]); + }); + it('always explains rejected/truncated input context and preserves all input names', () => { + const inputs = Object.fromEntries(Array.from({ length: 21 }, (_, i) => [`input-${i}`, `value-${i}`])); + const summary = renderActionSummary({ + title: 'Deploy', + status: 'warning', + inputContext: { inputs, inputsValidated: false, inputsTruncated: true }, + }); + for (const [name, value] of Object.entries(inputs)) expect(summary).toContain(`| ${name} | ${value} |`); + expect(summary).toContain('Validated: false; truncated: true.'); + expect(summary).toContain('Input validation did not complete'); + expect(summary).toContain('Some input values were truncated'); + }); + it('masks forged authentication snapshots across title, facts, issues and links', () => { + const secret = 'sensitive-marker'; + const summary = renderActionSummary({ + title: secret, + status: 'failure', + secrets: ['received-marker'], + facts: [{ label: secret, value: `${secret} received-marker eyJheader.payload.signature` }], + inputContext: { + inputs: { 'client-secret': secret, resource: secret, branch: secret }, + inputsValidated: true, + inputsTruncated: false, + }, + issues: [{ code: secret, message: `failure: ${secret}` }], + links: [{ label: secret, url: `https://github.example/${secret}/actions/runs/42` }], + }); + expect(summary).not.toContain(secret); + expect(summary).not.toContain('received-marker'); + expect(summary).not.toContain('eyJheader'); + expect(summary).toContain('| client-secret | \\[REDACTED\\] |'); + expect(summary).not.toContain('### Workflow'); + }); + it('escapes hostile Markdown, HTML, mentions and line breaks', () => { + const unsafe = ' | [link](javascript:bad) @team\n# forged'; + const summary = renderActionSummary({ + title: unsafe, + status: 'success', + facts: [{ label: unsafe, value: unsafe }], + }); + expect(summary).not.toContain('