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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ server-side records.
| `markpost sync [--dry-run]` | Fetch all pending records, write each to a markdown file, and (when `autoDelete` is enabled) delete the written records from the server. `--dry-run` reports the exact write/delete plan without writing or mutating anything |
| `markpost push <path...>` | Create records from one or more markdown files, directories, or glob patterns |
| `markpost get <uuid> [--json]` | Fetch and display a single record; pass `--json` for machine-readable output |
| `markpost sources <list\|create\|update\|delete> [uuid]` | Manage sources; `sources list --json` prints machine-readable output |
| `markpost sources <list\|create\|update\|delete\|rotate-secret> [uuid]` | Manage sources; `sources list --json` prints machine-readable output. `rotate-secret [uuid]` mints/replaces the signing secret of a provider source (github/zapier/shortcuts reveal a fresh secret once; stripe prompts for the new value) |
| `markpost records list [--source <type>] [--status <status>] [--search <text>] [--json]` | List records without deleting them, optionally filtered by source, status, or search text; pass `--json` for machine-readable output |
| `markpost config <get\|set\|path> [key] [value]` | View or change the stored API token and output directory |
| `markpost settings <get\|set> [key=value ...]` | View or change server-side sync settings (`autoSync`, `autoDelete`, `frontmatter`, `conflictStrategy`) |
Expand Down
160 changes: 148 additions & 12 deletions src/commands/sources.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,41 @@
import { parseArgs } from 'node:util';
import chalk from 'chalk';
import { input, select } from '@inquirer/prompts';
import { input, password, select } from '@inquirer/prompts';
import {
createSource,
deleteSource,
fetchSources,
rotateSourceSecret,
updateSource,
} from '@/libs/sources.js';
import { checkConfig } from '@/libs/config.js';
import { failWithMessage } from '@/libs/errors.js';
import { sanitizeForTerminal } from '@/libs/terminal.js';
import { failWithSubcommandUsage, failWithUsage } from '@/libs/usage.js';
import { hasJsonFlag, printJson } from '@/libs/output.js';
import { Source, SOURCE_TYPES, SourceType } from '@/types/sources.types.js';
import {
isManualSecretProvider,
isRotatableProvider,
ROTATABLE_PROVIDERS,
RotateSourceSecretInput,
Source,
SOURCE_TYPES,
SourceType,
} from '@/types/sources.types.js';

// Mirror the endpoint constants markpost's web app uses in
// app/composables/useSources.ts so the CLI shows the same URL a user would
// see there.
const WEBHOOK_INGEST_BASE = 'https://ingest.markpost.io/v1/hooks';
const EMAIL_DOMAIN = 'in.markpost.io';

export const USAGE = `Usage: markpost sources <list|create|update|delete> [uuid]
export const USAGE = `Usage: markpost sources <list|create|update|delete|rotate-secret> [uuid]

list List all sources (pass --json for machine-readable output)
create Create a new source (prompts for details)
update [uuid] Update a source's route folder; prompts to pick one if uuid is omitted
delete [uuid] Delete a source; prompts to pick one if uuid is omitted`;
list List all sources (pass --json for machine-readable output)
create Create a new source (prompts for details)
update [uuid] Update a source's route folder; prompts to pick one if uuid is omitted
delete [uuid] Delete a source; prompts to pick one if uuid is omitted
rotate-secret [uuid] Rotate a provider source's signing secret; prompts to pick one if uuid is omitted`;

export const buildEndpointUrl = (
sourceType: SourceType,
Expand Down Expand Up @@ -54,6 +64,7 @@ const SOURCES_HANDLERS = new Map<
['create', () => createSourceCommand()],
['update', (uuid) => updateSourceCommand(uuid)],
['delete', (uuid) => deleteSourceCommand(uuid)],
['rotate-secret', (uuid) => rotateSecretCommand(uuid)],
]);

export const runSourcesCommand = async (args: string[]): Promise<void> => {
Expand Down Expand Up @@ -249,13 +260,29 @@ const createSourceCommand = async (): Promise<void> => {
printProviderSecret(providerSecret);
};

// Shared by update and delete: list existing sources and let the user pick
// one, or report there's nothing to act on.
const promptForSource = async (action: string): Promise<Source | null> => {
const sources = await fetchSources();
// Shared by update, delete, and rotate-secret: list existing sources and let
// the user pick one, or report there's nothing to act on. `filter` narrows the
// choices to the sources an action can apply to (rotate-secret only offers
// provider-backed sources); it defaults to every source for update/delete.
// `emptyFilteredMessage` replaces the generic "no sources" line when sources
// exist but the filter removed all of them — so a user with only webhook/email
// sources learns rotate-secret needs a provider source, instead of being told
// they have none at all.
const promptForSource = async (
action: string,
filter: (source: Source) => boolean = () => true,
emptyFilteredMessage?: string,
): Promise<Source | null> => {
const allSources = await fetchSources();
const sources = allSources.filter(filter);

if (sources.length === 0) {
console.log(`No sources to ${action}.`);
const filteredOutSome = allSources.length > 0;
console.log(
filteredOutSome && emptyFilteredMessage
? emptyFilteredMessage
: `No sources to ${action}.`,
);
return null;
}

Expand Down Expand Up @@ -349,3 +376,112 @@ const deleteSourceCommand = async (uuid?: string): Promise<void> => {

console.log(chalk.greenBright(`Deleted ${meta.deleted} source(s).`));
};

// A manual-secret provider (stripe) issues its own secret, so rotation collects
// the new value from the user; a generated provider (github/zapier/shortcuts)
// sends no attributes and lets markpost mint one. Returns null when the user
// leaves a required secret blank, so the caller aborts without a doomed request
// (mirrors updateSource's empty-route-folder guard).
const collectRotateInput = async (
target: Source,
): Promise<RotateSourceSecretInput | null> => {
if (!isManualSecretProvider(target.provider)) {
return {};
}

// Masked: this is the one place the CLI accepts a signing secret, so it must
// not echo it into terminal scrollback, `script`/tmux captures, or CI logs.
const providerSecret = (
await password({
message: `New signing secret from ${sanitizeForTerminal(target.provider)}`,
mask: true,
})
).trim();

if (!providerSecret) {
console.error(chalk.redBright('Signing secret cannot be empty.'));
return null;
}

return { providerSecret };
};

const rotateSecretForSource = async (target: Source): Promise<void> => {
if (!isRotatableProvider(target.provider)) {
console.error(
chalk.redBright(
`Source "${sanitizeForTerminal(target.name)}" has no rotatable secret — only ${ROTATABLE_PROVIDERS.join(', ')} sources do.`,
),
);
return;
}

const rotateInput = await collectRotateInput(target);

if (!rotateInput) {
return;
}

const rotated = await rotateSourceSecret(target.uuid, rotateInput);

if (!rotated) {
// Exit non-zero (via failWithMessage) so a wrapper script/cron never reads
// a failed rotation as success. Unlike a failed create (nothing depended on
// the source yet), a failed rotate may already have committed server-side —
// a 5xx or unparseable body after the secret was replaced — leaving the old
// secret dead, so warn conditionally rather than implying nothing changed.
failWithMessage(
'Failed to rotate source secret. If the rotation was applied server-side the previous secret no longer works — run `markpost sources rotate-secret <uuid>` again to mint a secret you can copy.',
);
return;
}

// Peel the one-time secret off before the shared `printSource`, exactly as
// `createSourceCommand` does, so no printer that receives the source ever
// sees it. It is null for a manual-secret provider (the user already has it).
const { providerSecret, ...source } = rotated;
const isManual = isManualSecretProvider(target.provider);

// A generated provider's whole point is the one-time reveal; a response that
// omits it means the secret was rotated but is now unrecoverable, so the live
// integration is broken. Fail before printing any success line, so stdout
// never ends on "Rotated ..." for a broken integration.
if (!isManual && !providerSecret) {
failWithMessage(
'The secret was rotated but the server did not return it — the previous secret no longer works. Run `markpost sources rotate-secret <uuid>` again to mint one you can copy.',
);
return;
}

console.log(
chalk.greenBright(
`Rotated signing secret for "${sanitizeForTerminal(source.name)}"`,
),
);
printSource(source);

// Reveal only for a generated provider. A manual provider (stripe) issues its
// own secret — the user already has it — and an off-contract echo of it must
// never be printed, so suppress the reveal entirely here.
if (isManual) {
return;
}

printProviderSecret(providerSecret);
};

const rotateSecretCommand = async (uuid?: string): Promise<void> => {
const target = uuid
? await findSourceByUuid(uuid)
: await promptForSource(
'rotate the secret for',
(source) => isRotatableProvider(source.provider),
`None of your sources have a rotatable secret — only ${ROTATABLE_PROVIDERS.join(', ')} sources do.`,
);

if (!target) {
return;
}

await rotateSecretForSource(target);
};
119 changes: 74 additions & 45 deletions src/libs/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,83 +4,112 @@ import {
unwrapResourceAttributes,
unwrapResourceCollection,
} from '@/libs/api.js';
import { ApiDeleteMeta, ApiDeleteResponse } from '@/types/api.types.js';
import {
ApiDeleteMeta,
ApiDeleteResponse,
ApiResponse,
} from '@/types/api.types.js';
import {
CreatedSource,
CreateSourceApiResponse,
CreatedSourceResource,
CreateSourceInput,
RotateSourceSecretInput,
Source,
SourceApiResponse,
SourceListApiResponse,
SourceResource,
UpdateSourceInput,
} from '@/types/sources.types.js';

export const fetchSources = async (): Promise<Source[]> => {
try {
const body = (await authedRequest('/api/sources')) as SourceListApiResponse;

return unwrapResourceCollection('fetchSources', body, 'source');
} catch (error) {
logApiFailure('fetchSources', error);
const JSON_API_CONTENT_TYPE = 'application/vnd.api+json';

return [];
}
};

export const createSource = async (
input: CreateSourceInput,
): Promise<CreatedSource | null> => {
// The shared write seam for source POST/PATCH endpoints: they all send the same
// JSON:API `{ data: { type: 'sources', attributes } }` envelope and unwrap the
// resource attributes off the response, falling back to null (and logging) on
// failure. `context` labels the caller in the log line; `TResource` is the
// JSON:API resource the endpoint returns (`SourceResource`, or
// `CreatedSourceResource` for the two endpoints that reveal a one-time secret) —
// keeping those envelope types live so they still guard against markpost's
// `sourceSerializer` drifting (see src/types/sources.types.ts).
const writeSourceRequest = async <
TInput extends object,
TResource extends { attributes: unknown },
>(
context: string,
path: string,
method: 'POST' | 'PATCH',
attributes: TInput,
): Promise<TResource['attributes'] | null> => {
try {
const body = (await authedRequest('/api/sources', {
method: 'POST',
const body = (await authedRequest(path, {
method,
headers: {
'Content-Type': 'application/vnd.api+json',
'Content-Type': JSON_API_CONTENT_TYPE,
},
body: JSON.stringify({
data: {
type: 'sources',
attributes: input,
attributes,
},
}),
})) as CreateSourceApiResponse;
})) as ApiResponse<TResource | null>;

return unwrapResourceAttributes(body);
} catch (error) {
logApiFailure(`createSource["${input.name}"]`, error);
logApiFailure(context, error);

return null;
}
};

export const updateSource = async (
uuid: string,
input: UpdateSourceInput,
): Promise<Source | null> => {
export const fetchSources = async (): Promise<Source[]> => {
try {
const body = (await authedRequest(
`/api/sources/${encodeURIComponent(uuid)}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/vnd.api+json',
},
body: JSON.stringify({
data: {
type: 'sources',
attributes: input,
},
}),
},
)) as SourceApiResponse;
const body = (await authedRequest('/api/sources')) as SourceListApiResponse;

return unwrapResourceAttributes(body);
return unwrapResourceCollection('fetchSources', body, 'source');
} catch (error) {
logApiFailure(`updateSource["${uuid}"]`, error);
logApiFailure('fetchSources', error);

return null;
return [];
}
};

export const createSource = async (
input: CreateSourceInput,
): Promise<CreatedSource | null> =>
writeSourceRequest<CreateSourceInput, CreatedSourceResource>(
`createSource["${input.name}"]`,
'/api/sources',
'POST',
input,
);

export const updateSource = async (
uuid: string,
input: UpdateSourceInput,
): Promise<Source | null> =>
writeSourceRequest<UpdateSourceInput, SourceResource>(
`updateSource["${uuid}"]`,
`/api/sources/${encodeURIComponent(uuid)}`,
'PATCH',
input,
);

// Rotation reveals the freshly-generated signing secret exactly once, so its
// response carries `providerSecret` like `createSource` does — hence the
// `CreatedSource` shape rather than the base `Source`. `input` is empty for a
// generated provider and carries the pasted value for a manual-secret provider
// (stripe).
export const rotateSourceSecret = async (
uuid: string,
input: RotateSourceSecretInput = {},
): Promise<CreatedSource | null> =>
writeSourceRequest<RotateSourceSecretInput, CreatedSourceResource>(
`rotateSourceSecret["${uuid}"]`,
`/api/sources/${encodeURIComponent(uuid)}/rotate-secret`,
'POST',
input,
);

export const deleteSource = async (
uuid: string,
): Promise<ApiDeleteMeta | null> => {
Expand Down
Loading