diff --git a/packages/post-kit-publisher/LICENSE b/packages/post-kit-publisher/LICENSE new file mode 100644 index 0000000..c8b783b --- /dev/null +++ b/packages/post-kit-publisher/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Singleton SD + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/post-kit-publisher/README.md b/packages/post-kit-publisher/README.md new file mode 100644 index 0000000..b7b0972 --- /dev/null +++ b/packages/post-kit-publisher/README.md @@ -0,0 +1,91 @@ +# @singleton-sd/post-kit-publisher + +Compile Git-backed PostKit email templates and publish runtime artifacts to Azure Blob Storage for `apps/api` TemplateStore. + +## Install + +```bash +pnpm add @singleton-sd/post-kit-publisher +``` + +## CLI + +```bash +post-kit-publish \ + --templates ./content/email-templates \ + --tenant inkads \ + --environment production \ + --storage-account ssdpostkitstprodae \ + --container templates \ + --commit "$GITHUB_SHA" +``` + +Auth uses `DefaultAzureCredential` (Managed Identity in CI, `az login` locally). No connection strings. + +Blob layout (must match TemplateStore): + +```text +tenants/{tenant}/{environment}/templates/{templateKey}/template.html +tenants/{tenant}/{environment}/templates/{templateKey}/metadata.json +``` + +If any template fails to compile, the CLI exits non-zero and uploads nothing. + +## Library + +```ts +import { publishTemplates } from '@singleton-sd/post-kit-publisher'; + +const result = await publishTemplates({ + templatesDir: './content/email-templates', + tenant: 'inkads', + environment: 'production', + storageAccount: 'ssdpostkitstprodae', + container: 'templates', + commit: process.env.GITHUB_SHA, +}); +``` + +## GitHub Actions (OIDC) example + +```yaml +name: Publish email templates +on: + push: + paths: + - 'content/email-templates/**' +jobs: + publish: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: pnpm + - run: pnpm install --frozen-lockfile + - uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + - run: > + pnpm exec post-kit-publish + --templates ./content/email-templates + --tenant inkads + --environment production + --storage-account ssdpostkitstprodae + --container templates + --commit ${{ github.sha }} +``` + +## Development + +```bash +pnpm test +pnpm build +``` diff --git a/packages/post-kit-publisher/package.json b/packages/post-kit-publisher/package.json new file mode 100644 index 0000000..6f189f5 --- /dev/null +++ b/packages/post-kit-publisher/package.json @@ -0,0 +1,44 @@ +{ + "name": "@singleton-sd/post-kit-publisher", + "version": "0.1.0", + "private": false, + "description": "Compile and publish PostKit email templates to Azure Blob Storage", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "post-kit-publish": "./dist/bin/post-kit-publish.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "LICENSE" + ], + "scripts": { + "build": "pnpm --filter @singleton-sd/post-kit-types run build && pnpm --filter @singleton-sd/post-kit-compiler run build && tsc -p tsconfig.json", + "lint": "echo \"lint:publisher — covered by root eslint on staged files\"", + "test": "pnpm --filter @singleton-sd/post-kit-types run build && pnpm --filter @singleton-sd/post-kit-compiler run build && tsc -p tsconfig.spec.json && node --import tsx --test src/**/*.spec.ts" + }, + "devDependencies": { + "@types/node": "^20.17.9", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=20.18.1" + }, + "dependencies": { + "@azure/identity": "^4.13.1", + "@azure/storage-blob": "^12.33.0", + "@singleton-sd/post-kit-compiler": "workspace:*", + "@singleton-sd/post-kit-types": "workspace:*" + } +} diff --git a/packages/post-kit-publisher/src/bin/post-kit-publish.ts b/packages/post-kit-publisher/src/bin/post-kit-publish.ts new file mode 100644 index 0000000..58d55aa --- /dev/null +++ b/packages/post-kit-publisher/src/bin/post-kit-publish.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import { publishTemplates } from '../publish'; +import type { TenantEnvironment } from '@singleton-sd/post-kit-types'; + +function usage(): never { + console.error(`Usage: + post-kit-publish \\ + --templates \\ + --tenant \\ + --environment \\ + --storage-account \\ + --container \\ + [--commit ]`); + process.exit(2); +} + +function readFlag(argv: string[], name: string): string | undefined { + const idx = argv.indexOf(name); + if (idx === -1) return undefined; + return argv[idx + 1]; +} + +async function main(): Promise { + const argv = process.argv.slice(2); + if (argv.includes('--help') || argv.includes('-h')) usage(); + + const templates = readFlag(argv, '--templates'); + const tenant = readFlag(argv, '--tenant'); + const environment = readFlag(argv, '--environment'); + const storageAccount = readFlag(argv, '--storage-account'); + const container = readFlag(argv, '--container'); + const commit = readFlag(argv, '--commit'); + + if (!templates || !tenant || !environment || !storageAccount || !container) { + usage(); + } + + const result = await publishTemplates({ + templatesDir: templates, + tenant, + environment: environment as TenantEnvironment, + storageAccount, + container, + commit, + }); + + if (result.failed.length > 0) { + for (const failure of result.failed) { + console.error(`FAILED ${failure.key}: ${failure.error}`); + } + process.exit(1); + } + + console.error(`Published ${result.published.length} template(s): ${result.published.join(', ')}`); +} + +main().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/packages/post-kit-publisher/src/index.ts b/packages/post-kit-publisher/src/index.ts new file mode 100644 index 0000000..658fee7 --- /dev/null +++ b/packages/post-kit-publisher/src/index.ts @@ -0,0 +1,8 @@ +export { publishTemplates, type PublishOptions, type PublishResult } from './publish'; +export { + assertSafeTenantId, + assertSafeEnvironment, + assertSafeTemplateKey, + assertSafeStorageAccount, + blobBasePath, +} from './path-safety'; diff --git a/packages/post-kit-publisher/src/path-safety.ts b/packages/post-kit-publisher/src/path-safety.ts new file mode 100644 index 0000000..183c49b --- /dev/null +++ b/packages/post-kit-publisher/src/path-safety.ts @@ -0,0 +1,55 @@ +import type { TenantEnvironment } from '@singleton-sd/post-kit-types'; + +const SAFE_SEGMENT = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/; +const SAFE_TEMPLATE_KEY = /^[a-zA-Z0-9._-]+$/; +/** Azure Storage account names: 3–24 lowercase letters and digits. */ +const SAFE_STORAGE_ACCOUNT = /^[a-z0-9]{3,24}$/; +const ENVIRONMENTS = new Set(['development', 'staging', 'production']); + +export function assertSafeTenantId(tenant: string): void { + if (!tenant || !SAFE_SEGMENT.test(tenant) || tenant.includes('..')) { + throw new Error( + `Invalid tenant "${tenant}". Use alphanumeric characters and hyphens only (no path segments).`, + ); + } +} + +export function assertSafeEnvironment( + environment: string, +): asserts environment is TenantEnvironment { + if (!ENVIRONMENTS.has(environment as TenantEnvironment)) { + throw new Error( + `Invalid environment "${environment}". Must be one of: development, staging, production.`, + ); + } +} + +export function assertSafeTemplateKey(templateKey: string): void { + if ( + !templateKey || + templateKey === '.' || + templateKey === '..' || + !SAFE_TEMPLATE_KEY.test(templateKey) + ) { + throw new Error( + `Invalid template key "${templateKey}". Must match /^[a-zA-Z0-9._-]+$/ and must not be "." or "..".`, + ); + } +} + +export function assertSafeStorageAccount(storageAccount: string): void { + if (!SAFE_STORAGE_ACCOUNT.test(storageAccount)) { + throw new Error(`Invalid storage account "${storageAccount}". Must match /^[a-z0-9]{3,24}$/.`); + } +} + +export function blobBasePath( + tenant: string, + environment: TenantEnvironment, + templateKey: string, +): string { + assertSafeTenantId(tenant); + assertSafeEnvironment(environment); + assertSafeTemplateKey(templateKey); + return `tenants/${tenant}/${environment}/templates/${templateKey}`; +} diff --git a/packages/post-kit-publisher/src/publish.spec.ts b/packages/post-kit-publisher/src/publish.spec.ts new file mode 100644 index 0000000..ae3b653 --- /dev/null +++ b/packages/post-kit-publisher/src/publish.spec.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, cp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import type { BlobServiceClient, ContainerClient, BlockBlobClient } from '@azure/storage-blob'; +import { + assertSafeEnvironment, + assertSafeStorageAccount, + assertSafeTenantId, + assertSafeTemplateKey, + blobBasePath, +} from './path-safety'; +import { publishTemplatesWithClient } from './publish'; + +const FIXTURES = join(import.meta.dirname, '../../post-kit-compiler/src/fixtures'); + +describe('path safety', () => { + it('rejects unsafe tenant ids', () => { + assert.throws(() => assertSafeTenantId('../x')); + assert.throws(() => assertSafeTenantId('')); + assert.throws(() => assertSafeTenantId('a/b')); + assert.doesNotThrow(() => assertSafeTenantId('inkads')); + }); + + it('rejects invalid environments', () => { + assert.throws(() => assertSafeEnvironment('prod')); + assert.doesNotThrow(() => assertSafeEnvironment('production')); + }); + + it('rejects unsafe template keys', () => { + assert.throws(() => assertSafeTemplateKey('..')); + assert.throws(() => assertSafeTemplateKey('.')); + assert.throws(() => assertSafeTemplateKey('a/b')); + assert.doesNotThrow(() => assertSafeTemplateKey('marketing.contact-us')); + }); + + it('rejects unsafe storage account names', () => { + assert.throws(() => assertSafeStorageAccount('attacker.example/')); + assert.throws(() => assertSafeStorageAccount('Ab')); + assert.throws(() => assertSafeStorageAccount('UPPERCASEACCOUNT')); + assert.doesNotThrow(() => assertSafeStorageAccount('ssdpostkitstprodae')); + }); + + it('builds the TemplateStore blob base path', () => { + assert.equal( + blobBasePath('inkads', 'production', 'marketing.contact-us'), + 'tenants/inkads/production/templates/marketing.contact-us', + ); + }); +}); + +describe('publishTemplates', () => { + it('does not upload when compilation fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'post-kit-publish-')); + await cp(join(FIXTURES, 'malformed-metadata'), join(root, 'bad'), { recursive: true }); + await writeFile( + join(root, 'bad', 'template.json'), + JSON.stringify({ root: { type: 'EmailLayout', data: { childrenIds: [] } } }), + ); + await writeFile(join(root, 'bad', 'preview.json'), '{}'); + + let uploads = 0; + const client = makeFakeClient(() => { + uploads += 1; + }); + + const result = await publishTemplatesWithClient( + { + templatesDir: root, + tenant: 'inkads', + environment: 'development', + storageAccount: 'ssdpostkitstprodae', + container: 'templates', + }, + client, + ); + + assert.equal(result.published.length, 0); + assert.ok(result.failed.length >= 1); + assert.equal(uploads, 0); + }); + + it('does not upload when two directories share the same template key', async () => { + const root = await mkdtemp(join(tmpdir(), 'post-kit-publish-')); + await cp(join(FIXTURES, 'marketing.contact-us'), join(root, 'copy-a'), { recursive: true }); + await cp(join(FIXTURES, 'marketing.contact-us'), join(root, 'copy-b'), { recursive: true }); + + let uploads = 0; + const client = makeFakeClient(() => { + uploads += 1; + }); + + const result = await publishTemplatesWithClient( + { + templatesDir: root, + tenant: 'inkads', + environment: 'production', + storageAccount: 'ssdpostkitstprodae', + container: 'templates', + }, + client, + ); + + assert.equal(result.published.length, 0); + assert.ok(result.failed.some((f) => f.error.includes('Duplicate template key'))); + assert.equal(uploads, 0); + }); + + it('uploads template.html and metadata.json for a valid fixture', async () => { + const root = await mkdtemp(join(tmpdir(), 'post-kit-publish-')); + await cp(join(FIXTURES, 'marketing.contact-us'), join(root, 'marketing.contact-us'), { + recursive: true, + }); + + const uploaded = new Map(); + const client = makeFakeClient((path, body) => { + uploaded.set(path, body); + }); + + const result = await publishTemplatesWithClient( + { + templatesDir: root, + tenant: 'inkads', + environment: 'production', + storageAccount: 'ssdpostkitstprodae', + container: 'templates', + commit: 'abc123', + }, + client, + ); + + assert.deepEqual(result.published, ['marketing.contact-us']); + assert.equal(result.failed.length, 0); + assert.ok( + uploaded.has('tenants/inkads/production/templates/marketing.contact-us/template.html'), + ); + assert.ok( + uploaded.has('tenants/inkads/production/templates/marketing.contact-us/metadata.json'), + ); + const meta = JSON.parse( + uploaded.get('tenants/inkads/production/templates/marketing.contact-us/metadata.json')!, + ); + assert.equal(meta.key, 'marketing.contact-us'); + const html = uploaded.get( + 'tenants/inkads/production/templates/marketing.contact-us/template.html', + )!; + assert.ok(html.includes(' 0); + }); +}); + +function makeFakeClient(onUpload: (path: string, body: string) => void): BlobServiceClient { + const getBlockBlobClient = (blobPath: string): BlockBlobClient => + ({ + upload: async (body: string | Buffer) => { + const text = typeof body === 'string' ? body : body.toString('utf8'); + onUpload(blobPath, text); + return {}; + }, + }) as unknown as BlockBlobClient; + + const getContainerClient = (): ContainerClient => + ({ getBlockBlobClient }) as unknown as ContainerClient; + + return { getContainerClient } as unknown as BlobServiceClient; +} diff --git a/packages/post-kit-publisher/src/publish.ts b/packages/post-kit-publisher/src/publish.ts new file mode 100644 index 0000000..e718bf4 --- /dev/null +++ b/packages/post-kit-publisher/src/publish.ts @@ -0,0 +1,155 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { BlobServiceClient } from '@azure/storage-blob'; +import { DefaultAzureCredential } from '@azure/identity'; +import { compileFromDirectory } from '@singleton-sd/post-kit-compiler'; +import type { CompiledTemplate, TenantEnvironment } from '@singleton-sd/post-kit-types'; +import { + assertSafeEnvironment, + assertSafeStorageAccount, + assertSafeTenantId, + assertSafeTemplateKey, + blobBasePath, +} from './path-safety'; + +export interface PublishOptions { + /** Root directory; each subdirectory is one template. */ + templatesDir: string; + tenant: string; + environment: TenantEnvironment; + storageAccount: string; + container: string; + /** Passed through to TemplateManifest.sourceCommit. */ + commit?: string; +} + +export interface PublishResult { + published: string[]; + failed: Array<{ key: string; error: string }>; +} + +/** Internal batch row — not part of the public package API. */ +interface CompiledEntry { + dirName: string; + compiled: CompiledTemplate; +} + +/** + * Compile every template under `templatesDir`, then upload artifacts. + * + * Fail-fast for publishing: if any compile fails, nothing is uploaded. + * Storage auth always uses `DefaultAzureCredential` (Managed Identity / az login). + */ +export async function publishTemplates(options: PublishOptions): Promise { + assertSafeTenantId(options.tenant); + assertSafeEnvironment(options.environment); + assertSafeStorageAccount(options.storageAccount); + + const client = new BlobServiceClient( + `https://${options.storageAccount}.blob.core.windows.net`, + new DefaultAzureCredential(), + ); + return runPublish(options, client); +} + +/** + * Test-only seam that injects a Blob client. Not re-exported from the package root; + * package `exports` only expose `.` so consumers cannot import this via the public API. + */ +export async function publishTemplatesWithClient( + options: PublishOptions, + client: BlobServiceClient, +): Promise { + assertSafeTenantId(options.tenant); + assertSafeEnvironment(options.environment); + assertSafeStorageAccount(options.storageAccount); + return runPublish(options, client); +} + +async function runPublish( + options: PublishOptions, + client: BlobServiceClient, +): Promise { + const entries = await listTemplateDirs(options.templatesDir); + const compiled: CompiledEntry[] = []; + const failed: PublishResult['failed'] = []; + const seenKeys = new Set(); + + for (const dirName of entries) { + const dir = join(options.templatesDir, dirName); + try { + const result = await compileFromDirectory(dir, { sourceCommit: options.commit ?? '' }); + assertSafeTemplateKey(result.metadata.key); + if (seenKeys.has(result.metadata.key)) { + failed.push({ + key: dirName, + error: `Duplicate template key "${result.metadata.key}" (already compiled from another directory).`, + }); + continue; + } + seenKeys.add(result.metadata.key); + compiled.push({ dirName, compiled: result }); + } catch (err) { + failed.push({ + key: dirName, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + if (failed.length > 0) { + return { published: [], failed }; + } + + const containerClient = client.getContainerClient(options.container); + const published: string[] = []; + + for (const entry of compiled) { + const key = entry.compiled.metadata.key; + const base = blobBasePath(options.tenant, options.environment, key); + const htmlPath = `${base}/template.html`; + const metaPath = `${base}/metadata.json`; + + await containerClient + .getBlockBlobClient(htmlPath) + .upload(entry.compiled.templateHtml, Buffer.byteLength(entry.compiled.templateHtml, 'utf8'), { + blobHTTPHeaders: { blobContentType: 'text/html; charset=utf-8' }, + }); + const metadataJson = JSON.stringify(entry.compiled.metadata, null, 2); + await containerClient + .getBlockBlobClient(metaPath) + .upload(metadataJson, Buffer.byteLength(metadataJson, 'utf8'), { + blobHTTPHeaders: { blobContentType: 'application/json; charset=utf-8' }, + }); + + published.push(key); + console.log( + JSON.stringify({ + key, + contentHash: entry.compiled.manifest.contentHash, + templateHtml: htmlPath, + metadataJson: metaPath, + }), + ); + } + + return { published, failed: [] }; +} + +async function listTemplateDirs(templatesDir: string): Promise { + const names = await readdir(templatesDir, { withFileTypes: true }); + const dirs = names.filter((d) => d.isDirectory()).map((d) => d.name); + dirs.sort(); + + for (const name of dirs) { + for (const file of ['template.json', 'metadata.json', 'preview.json'] as const) { + try { + await readFile(join(templatesDir, name, file)); + } catch { + throw new Error(`Template directory "${name}" is missing required file ${file}`); + } + } + } + + return dirs; +} diff --git a/packages/post-kit-publisher/tsconfig.json b/packages/post-kit-publisher/tsconfig.json new file mode 100644 index 0000000..7989add --- /dev/null +++ b/packages/post-kit-publisher/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/packages/post-kit-publisher/tsconfig.spec.json b/packages/post-kit-publisher/tsconfig.spec.json new file mode 100644 index 0000000..c4325e5 --- /dev/null +++ b/packages/post-kit-publisher/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "module": "es2022", + "moduleResolution": "bundler" + }, + "include": ["src/**/*"], + "exclude": [] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a589f4c..b8027ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,31 @@ importers: specifier: ^5.7.2 version: 5.9.3 + packages/post-kit-publisher: + dependencies: + '@azure/identity': + specifier: ^4.13.1 + version: 4.13.2 + '@azure/storage-blob': + specifier: ^12.33.0 + version: 12.33.0 + '@singleton-sd/post-kit-compiler': + specifier: workspace:* + version: link:../post-kit-compiler + '@singleton-sd/post-kit-types': + specifier: workspace:* + version: link:../post-kit-types + devDependencies: + '@types/node': + specifier: ^20.17.9 + version: 20.19.43 + tsx: + specifier: ^4.19.2 + version: 4.23.12 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + packages/post-kit-types: devDependencies: '@types/node':