@amalgm/automations 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AXIOMS.md +32 -0
  2. package/PURPOSE.md +35 -0
  3. package/README.md +130 -0
  4. package/dist/src/automations.d.ts +27 -0
  5. package/dist/src/automations.js +199 -0
  6. package/dist/src/cli-main.d.ts +2 -0
  7. package/dist/src/cli-main.js +21 -0
  8. package/dist/src/cli.d.ts +10 -0
  9. package/dist/src/cli.js +183 -0
  10. package/dist/src/client.d.ts +7 -0
  11. package/dist/src/client.js +85 -0
  12. package/dist/src/contract.d.ts +176 -0
  13. package/dist/src/contract.js +1 -0
  14. package/dist/src/crud.d.ts +32 -0
  15. package/dist/src/crud.js +241 -0
  16. package/dist/src/errors.d.ts +17 -0
  17. package/dist/src/errors.js +32 -0
  18. package/dist/src/http.d.ts +7 -0
  19. package/dist/src/http.js +154 -0
  20. package/dist/src/index.d.ts +14 -0
  21. package/dist/src/index.js +18 -0
  22. package/dist/src/mcp-main.d.ts +2 -0
  23. package/dist/src/mcp-main.js +17 -0
  24. package/dist/src/mcp.d.ts +3 -0
  25. package/dist/src/mcp.js +88 -0
  26. package/dist/src/schedule.d.ts +2 -0
  27. package/dist/src/schedule.js +10 -0
  28. package/dist/src/schema.d.ts +236 -0
  29. package/dist/src/schema.js +119 -0
  30. package/dist/src/supabase-crud.d.ts +44 -0
  31. package/dist/src/supabase-crud.js +271 -0
  32. package/dist/src/supabase-store.d.ts +26 -0
  33. package/dist/src/supabase-store.js +123 -0
  34. package/dist/src/types.d.ts +128 -0
  35. package/dist/src/types.js +1 -0
  36. package/dist/src/validation.d.ts +11 -0
  37. package/dist/src/validation.js +69 -0
  38. package/dist/src/webhook.d.ts +18 -0
  39. package/dist/src/webhook.js +60 -0
  40. package/package.json +66 -0
  41. package/supabase/migrations/20260802000000_create_automations.sql +609 -0
@@ -0,0 +1,128 @@
1
+ export type Json = null | boolean | number | string | Json[] | {
2
+ [key: string]: Json;
3
+ };
4
+ interface TriggerBase {
5
+ id: string;
6
+ enabled?: boolean;
7
+ }
8
+ export interface CronTrigger extends TriggerBase {
9
+ kind: 'cron';
10
+ cron: string;
11
+ timezone?: string;
12
+ }
13
+ export interface EventTrigger extends TriggerBase {
14
+ kind: 'event';
15
+ source?: string;
16
+ event?: string;
17
+ secret: string;
18
+ }
19
+ export type Trigger = CronTrigger | EventTrigger;
20
+ export interface WorkflowDefinition {
21
+ id: string;
22
+ name?: string;
23
+ script: string;
24
+ compiled?: Json;
25
+ allowlist?: Json;
26
+ limits?: Json;
27
+ }
28
+ export interface AutomationDefinition {
29
+ id: string;
30
+ targetId: string;
31
+ name?: string;
32
+ description?: string;
33
+ enabled?: boolean;
34
+ trigger: Trigger;
35
+ workflow: WorkflowDefinition;
36
+ }
37
+ /** The only authenticated identity Automations accepts from Core. */
38
+ export interface AutomationTarget {
39
+ userId: string;
40
+ targetId: string;
41
+ }
42
+ export type PreparedTrigger = (CronTrigger & {
43
+ enabled: boolean;
44
+ timezone: string;
45
+ nextRunAt: string;
46
+ }) | (EventTrigger & {
47
+ enabled: boolean;
48
+ source: string;
49
+ event: string;
50
+ });
51
+ export interface PreparedAutomation extends Omit<AutomationDefinition, 'enabled' | 'trigger'> {
52
+ userId: string;
53
+ enabled: boolean;
54
+ trigger: PreparedTrigger;
55
+ }
56
+ interface StoredTriggerBase {
57
+ userId: string;
58
+ automationId: string;
59
+ triggerId: string;
60
+ targetId: string;
61
+ }
62
+ export interface StoredEventTrigger extends StoredTriggerBase {
63
+ kind: 'event';
64
+ source: string;
65
+ event: string;
66
+ secret: string;
67
+ }
68
+ export interface DueCronTrigger extends StoredTriggerBase {
69
+ kind: 'cron';
70
+ cron: string;
71
+ timezone: string;
72
+ nextRunAt: string;
73
+ }
74
+ export type RunInput = {
75
+ kind: 'event';
76
+ source: string;
77
+ event: string;
78
+ payload: Json;
79
+ } | {
80
+ kind: 'cron';
81
+ scheduledFor: string;
82
+ };
83
+ export type RunStatus = 'pending' | 'sent' | 'running' | 'completed' | 'failed';
84
+ export interface AutomationRun {
85
+ id: string;
86
+ userId: string;
87
+ targetId: string;
88
+ automationId: string;
89
+ triggerId: string;
90
+ workflowId: string;
91
+ automation: Json;
92
+ input: RunInput;
93
+ status: RunStatus;
94
+ createdAt: string;
95
+ sentAt?: string;
96
+ startedAt?: string;
97
+ finishedAt?: string;
98
+ output?: Json;
99
+ error?: string;
100
+ }
101
+ export interface RunUpdate {
102
+ status: 'running' | 'completed' | 'failed';
103
+ startedAt?: string;
104
+ finishedAt?: string;
105
+ output?: Json;
106
+ error?: string;
107
+ }
108
+ export interface AutomationStore {
109
+ saveAutomation(automation: PreparedAutomation): Promise<void>;
110
+ deleteAutomation(userId: string, automationId: string): Promise<boolean>;
111
+ eventTriggers(target: AutomationTarget): Promise<StoredEventTrigger[]>;
112
+ dueCronTriggers(now: Date): Promise<DueCronTrigger[]>;
113
+ enqueueEvent(trigger: StoredEventTrigger, input: Extract<RunInput, {
114
+ kind: 'event';
115
+ }>, now: Date): Promise<AutomationRun | null>;
116
+ enqueueCron(trigger: DueCronTrigger, nextRunAt: string, now: Date): Promise<AutomationRun | null>;
117
+ pendingRuns(target: AutomationTarget): Promise<AutomationRun[]>;
118
+ listRuns(userId: string, automationId?: string): Promise<AutomationRun[]>;
119
+ markSent(runId: string, target: AutomationTarget, now: Date): Promise<boolean>;
120
+ updateRun(target: AutomationTarget, runId: string, update: RunUpdate): Promise<AutomationRun | null>;
121
+ }
122
+ /** The complete transport capability Automations needs from Core. */
123
+ export interface AutomationTransport {
124
+ isOnline(target: AutomationTarget): boolean;
125
+ send(run: AutomationRun): Promise<boolean>;
126
+ }
127
+ export type AutomationLog = (event: 'event.rejected' | 'run.pending' | 'drain.started' | 'run.sent' | 'run.failed', details: Readonly<Record<string, string>>) => void;
128
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import type { Json, PageQuery } from './contract.js';
2
+ export declare const IDENTIFIER: RegExp;
3
+ export declare function newId(prefix: string): string;
4
+ export declare function id(value: string, label: string): string;
5
+ export declare function optionalText(value: string | null | undefined, label: string): string | null | undefined;
6
+ export declare function requiredText(value: string, label: string): string;
7
+ export declare function webhookSecret(value: string): string;
8
+ export declare function schedule(cron: string, timezone: string): void;
9
+ export declare function json(value: unknown, label: string): asserts value is Json;
10
+ export declare function page(query: PageQuery | undefined): Required<PageQuery>;
11
+ export declare function nonEmptyPatch(patch: object, label: string): void;
@@ -0,0 +1,69 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { CronExpressionParser } from 'cron-parser';
3
+ import { ValidationError } from './errors.js';
4
+ export const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
5
+ const MAX_PAGE_SIZE = 100;
6
+ export function newId(prefix) {
7
+ return `${prefix}_${randomUUID()}`;
8
+ }
9
+ export function id(value, label) {
10
+ if (!IDENTIFIER.test(value))
11
+ throw new ValidationError(`${label} is invalid`);
12
+ return value;
13
+ }
14
+ export function optionalText(value, label) {
15
+ if (value === undefined || value === null)
16
+ return value;
17
+ if (!value.trim())
18
+ throw new ValidationError(`${label} cannot be blank`);
19
+ return value;
20
+ }
21
+ export function requiredText(value, label) {
22
+ if (!value || !value.trim())
23
+ throw new ValidationError(`${label} is required`);
24
+ return value;
25
+ }
26
+ export function webhookSecret(value) {
27
+ if (value.length < 16)
28
+ throw new ValidationError('Webhook secret must be at least 16 characters');
29
+ return value;
30
+ }
31
+ export function schedule(cron, timezone) {
32
+ requiredText(cron, 'Schedule cron');
33
+ requiredText(timezone, 'Schedule timezone');
34
+ try {
35
+ CronExpressionParser.parse(cron, { currentDate: new Date(0), tz: timezone });
36
+ }
37
+ catch {
38
+ throw new ValidationError('Schedule cron or timezone is invalid');
39
+ }
40
+ }
41
+ export function json(value, label) {
42
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
43
+ return;
44
+ if (typeof value === 'number' && Number.isFinite(value))
45
+ return;
46
+ if (Array.isArray(value)) {
47
+ value.forEach((item) => json(item, label));
48
+ return;
49
+ }
50
+ if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
51
+ Object.values(value).forEach((item) => json(item, label));
52
+ return;
53
+ }
54
+ throw new ValidationError(`${label} must be JSON`);
55
+ }
56
+ export function page(query) {
57
+ const limit = query?.limit ?? 20;
58
+ const offset = query?.offset ?? 0;
59
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE) {
60
+ throw new ValidationError(`limit must be an integer from 1 to ${MAX_PAGE_SIZE}`);
61
+ }
62
+ if (!Number.isInteger(offset) || offset < 0)
63
+ throw new ValidationError('offset must be a non-negative integer');
64
+ return { limit, offset };
65
+ }
66
+ export function nonEmptyPatch(patch, label) {
67
+ if (Object.keys(patch).length === 0)
68
+ throw new ValidationError(`${label} must include a change`);
69
+ }
@@ -0,0 +1,18 @@
1
+ import type { Json, StoredEventTrigger } from './types.js';
2
+ export declare function normalizeHeaders(headers: Record<string, string>): Record<string, string>;
3
+ export declare function verifyEventSecret(secret: string, headers: Record<string, string>, body: Buffer): boolean;
4
+ export declare function matchesEvent(trigger: Pick<StoredEventTrigger, 'source' | 'event'>, source: string, event: string): boolean;
5
+ export declare function eventReferences(headers: Record<string, string>, payload: Json): {
6
+ primary: {
7
+ source: string;
8
+ event: string;
9
+ };
10
+ fallback?: {
11
+ source: string;
12
+ event: string;
13
+ };
14
+ };
15
+ export declare function eventReference(headers: Record<string, string>, payload: Json): {
16
+ source: string;
17
+ event: string;
18
+ };
@@ -0,0 +1,60 @@
1
+ import crypto from 'node:crypto';
2
+ const HMAC_HEADERS = ['x-webhook-signature', 'x-hub-signature-256', 'x-hub-signature'];
3
+ const TOKEN_HEADERS = ['x-amalgm-webhook-secret', 'x-webhook-secret', 'x-gitlab-token'];
4
+ function equal(left, right) {
5
+ const a = Buffer.from(left);
6
+ const b = Buffer.from(right);
7
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
8
+ }
9
+ export function normalizeHeaders(headers) {
10
+ return Object.fromEntries(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]));
11
+ }
12
+ export function verifyEventSecret(secret, headers, body) {
13
+ const normalized = normalizeHeaders(headers);
14
+ for (const name of HMAC_HEADERS) {
15
+ const supplied = normalized[name];
16
+ if (!supplied)
17
+ continue;
18
+ const algorithm = supplied.startsWith('sha1=') ? 'sha1' : 'sha256';
19
+ const expected = `${algorithm}=${crypto.createHmac(algorithm, secret).update(body).digest('hex')}`;
20
+ return equal(supplied, expected);
21
+ }
22
+ const bearer = normalized.authorization?.match(/^Bearer\s+(.+)$/i)?.[1];
23
+ if (bearer && equal(bearer, secret))
24
+ return true;
25
+ for (const name of TOKEN_HEADERS) {
26
+ if (normalized[name] && equal(normalized[name], secret))
27
+ return true;
28
+ }
29
+ return false;
30
+ }
31
+ export function matchesEvent(trigger, source, event) {
32
+ return (trigger.source === '*' || trigger.source === source)
33
+ && (trigger.event === '*' || trigger.event === event);
34
+ }
35
+ export function eventReferences(headers, payload) {
36
+ const h = normalizeHeaders(headers);
37
+ // Source-label precedence is kernel law (@amalgm/core pickSourceLabel):
38
+ // github → stripe → linear → gitlab.
39
+ if (h['x-github-event'])
40
+ return { primary: { source: 'github', event: h['x-github-event'] } };
41
+ if (h['stripe-signature'])
42
+ return { primary: { source: 'stripe', event: 'webhook' } };
43
+ if (h['x-linear-event'])
44
+ return { primary: { source: 'linear', event: h['x-linear-event'] } };
45
+ if (h['x-gitlab-event'])
46
+ return { primary: { source: 'gitlab', event: h['x-gitlab-event'] } };
47
+ const object = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
48
+ const declaredByHeader = h['x-amalgm-source'] || h['x-amalgm-event'];
49
+ const declaredByBody = typeof object.source === 'string' || typeof object.event === 'string';
50
+ const primary = {
51
+ source: h['x-amalgm-source'] || (typeof object.source === 'string' ? object.source : 'external'),
52
+ event: h['x-amalgm-event'] || (typeof object.event === 'string' ? object.event : 'webhook'),
53
+ };
54
+ return declaredByBody && !declaredByHeader
55
+ ? { primary, fallback: { source: 'external', event: 'webhook' } }
56
+ : { primary };
57
+ }
58
+ export function eventReference(headers, payload) {
59
+ return eventReferences(headers, payload).primary;
60
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@amalgm/automations",
3
+ "version": "0.1.0",
4
+ "description": "Amalgm's automation product: Supabase-backed configuration plus the delivery rail (receipts, leases, retries).",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/amalgm-inc/amalgm-automations.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "type": "module",
14
+ "private": false,
15
+ "main": "./dist/src/index.js",
16
+ "types": "./dist/src/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/src/index.d.ts",
20
+ "import": "./dist/src/index.js"
21
+ },
22
+ "./cli": {
23
+ "types": "./dist/src/cli.d.ts",
24
+ "import": "./dist/src/cli.js"
25
+ },
26
+ "./mcp": {
27
+ "types": "./dist/src/mcp.d.ts",
28
+ "import": "./dist/src/mcp.js"
29
+ }
30
+ },
31
+ "bin": {
32
+ "amalgm-automations": "./dist/src/cli-main.js",
33
+ "amalgm-automations-mcp": "./dist/src/mcp-main.js"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "supabase",
38
+ "AXIOMS.md",
39
+ "PURPOSE.md",
40
+ "README.md"
41
+ ],
42
+ "scripts": {
43
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
44
+ "check": "tsx scripts/check-tree.ts && tsc -p tsconfig.json --noEmit",
45
+ "test": "tsx --test test/*.test.ts",
46
+ "test:supabase": "tsx --test test/integration/postgres.test.ts",
47
+ "verify": "npm run check && npm test && npm run build",
48
+ "release:check": "npm run verify && npm run test:supabase",
49
+ "prepack": "npm run build"
50
+ },
51
+ "engines": {
52
+ "node": ">=20"
53
+ },
54
+ "dependencies": {
55
+ "@modelcontextprotocol/sdk": "^1.30.0",
56
+ "cron-parser": "^5.4.0",
57
+ "zod": "^4.4.3"
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^26.1.2",
61
+ "@types/pg": "^8.20.1",
62
+ "pg": "^8.16.3",
63
+ "tsx": "^4.23.1",
64
+ "typescript": "^7.0.2"
65
+ }
66
+ }