@thejoseki/clawform 0.0.1 → 2.2.1

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 (55) hide show
  1. package/LICENSE +95 -4
  2. package/README.md +93 -10
  3. package/bin/clawform.js +151 -16
  4. package/package.json +47 -11
  5. package/src/aws/catalog.js +116 -0
  6. package/src/aws/changeset.js +231 -0
  7. package/src/aws/drift.js +59 -0
  8. package/src/aws/exports.js +213 -0
  9. package/src/aws/inventory.js +156 -0
  10. package/src/commands/activate.js +105 -0
  11. package/src/commands/ci-init.js +212 -0
  12. package/src/commands/cost-report.js +166 -0
  13. package/src/commands/deactivate.js +36 -0
  14. package/src/commands/deploy.js +413 -0
  15. package/src/commands/diff.js +39 -0
  16. package/src/commands/drift.js +35 -0
  17. package/src/commands/init-plugin.js +168 -0
  18. package/src/commands/init.js +360 -0
  19. package/src/commands/license-status.js +28 -0
  20. package/src/commands/rollback.js +126 -0
  21. package/src/commands/status.js +150 -0
  22. package/src/commands/sync.js +53 -0
  23. package/src/commands/validate.js +349 -0
  24. package/src/hooks/block-dangerous-eval.js +160 -0
  25. package/src/hooks/block-dangerous.js +62 -0
  26. package/src/hooks/cfn-lint-after-edit-eval.js +30 -0
  27. package/src/hooks/cfn-lint-after-edit.js +81 -0
  28. package/src/hooks/check-catalog-fresh-eval.js +63 -0
  29. package/src/hooks/check-catalog-fresh.js +33 -0
  30. package/src/hooks/session-context-eval.js +81 -0
  31. package/src/hooks/session-context.js +41 -0
  32. package/src/hooks/subagent-context-eval.js +44 -0
  33. package/src/hooks/subagent-context.js +48 -0
  34. package/src/lib/audit-synthesis.js +24 -0
  35. package/src/lib/aws-client.js +15 -0
  36. package/src/lib/cfn-yaml.js +92 -0
  37. package/src/lib/config.js +76 -0
  38. package/src/lib/constants.js +85 -0
  39. package/src/lib/defaults.js +16 -0
  40. package/src/lib/fingerprint.js +65 -0
  41. package/src/lib/install-workflow.js +28 -0
  42. package/src/lib/kit-source.js +81 -0
  43. package/src/lib/license-client.js +84 -0
  44. package/src/lib/license-config.js +162 -0
  45. package/src/lib/license-store.js +107 -0
  46. package/src/lib/license.js +150 -0
  47. package/src/lib/lint-overrides.js +226 -0
  48. package/src/lib/logger.js +17 -0
  49. package/src/lib/payload.js +74 -0
  50. package/src/lib/project-config.js +145 -0
  51. package/src/lib/providers/polar.js +215 -0
  52. package/src/lib/rename-compat.js +50 -0
  53. package/src/lib/session-state.js +91 -0
  54. package/src/lib/trial-claim.js +65 -0
  55. package/src/lib/watermark.js +65 -0
@@ -0,0 +1,85 @@
1
+ export const ENVS = ['d', 'stg', 'p'];
2
+
3
+ // The customer token is the widest variable slot in every generated name, and
4
+ // the tightest consumer is the 32-char cap AWS puts on load-balancer and
5
+ // target-group names. Eight characters is what that budget leaves once the
6
+ // longest env, the fixed infix, and a usable project shortname are accounted
7
+ // for — see the arithmetic in templates/alb.yaml. The archetypes'
8
+ // AllowedPattern and the init wizard both have to agree with this number, or
9
+ // the wizard writes a config that fails at deploy time.
10
+ export const CUSTOMER_MAX_LEN = 8;
11
+ export const CUSTOMER_RE = new RegExp(`^[a-z0-9]{1,${CUSTOMER_MAX_LEN}}$`);
12
+
13
+ // Catalog + inventory are generated from the consumer's own AWS account, so
14
+ // they belong in the consumer's project — not in the plugin checkout, where
15
+ // they would be shared across every project, wiped by `npm update`, and out of
16
+ // reach of the deploy-gate hook (which resolves them relative to the project).
17
+ // Dotted and single-rooted so a buyer gitignores one entry.
18
+ export const CATALOG_DIR_REL = '.clawform/catalog';
19
+
20
+ export const VISIBILITY_PREFIXES = ['pri', 'pub'];
21
+
22
+ // When adding: update rules/cfn-standards.md service-shortname table in the same change.
23
+ // Consumers can extend this list via `service_shortnames_extra` in clawform.config.json
24
+ // without forking the plugin.
25
+ export const SERVICE_SHORTNAMES = [
26
+ 'vpc', 'snet', 'sg', 'rt', 'vpce', 'igw', 'nat', 'eip',
27
+ 'alb', 'nlb', 'tg', 'lstn',
28
+ 'ecsc', 'ecss', 'ecr',
29
+ 'rds', 'aur', 'ddb', 'ecache',
30
+ 's3', 'lmbd',
31
+ 'sns', 'sqs', 'glue',
32
+ 'cf', 'r53', 'acm', 'cgnp', 'wafa',
33
+ 'sm', 'cwlg', 'cwa', 'bgt',
34
+ 'iamr', 'kms', 'ec2',
35
+ 'ccmt', 'cbld', 'cpl', 'cdpl',
36
+ ];
37
+
38
+ // The legacy-prefix list moved out of the plugin and into the consumer's
39
+ // clawform.config.json (`legacy_prefixes`). See src/lib/project-config.js
40
+ // and docs/config-schema.md. A fresh consumer with no config has no legacy
41
+ // stacks, so the built-in default is an empty list — we don't impose Acme's
42
+ // LEGACY/OLDAPP freeze on someone else's account.
43
+
44
+ // Anchored to line-start to defeat prompt-injection via "FORCE LEGACY" inside narrative content.
45
+ export const LEGACY_OVERRIDE_PHRASE = /^\s*#\s*FORCE\s+LEGACY\b/m;
46
+
47
+ // Stateful CFN resource types per CLAUDE.md hard rule #4. Replace on any of these
48
+ // requires the data-resource gate in src/commands/deploy.js (user must retype stack name).
49
+ //
50
+ // IMPORTANT: list only types that actually hold data. The RDS/EFS/ElastiCache namespaces
51
+ // each contain config types (SubnetGroup, ParameterGroup, MountTarget, OptionGroup, ...)
52
+ // that hold no data — including them via a bare-prefix match would over-fire the safety
53
+ // gate on harmless config Replaces and train operators to ignore the warning. Add new
54
+ // stateful types here explicitly; don't reintroduce DATA_RESOURCE_PREFIXES.
55
+ export const DATA_RESOURCE_EXACT = [
56
+ // Cognito — a user pool holds every user account; a Replace repoints the
57
+ // export at a brand-new EMPTY pool. Treated as data per hard rule #4.
58
+ 'AWS::Cognito::UserPool',
59
+ // S3 / DynamoDB / Logs / Secrets / KMS
60
+ 'AWS::S3::Bucket',
61
+ 'AWS::DynamoDB::Table',
62
+ 'AWS::DynamoDB::GlobalTable',
63
+ 'AWS::Logs::LogGroup',
64
+ 'AWS::SecretsManager::Secret',
65
+ 'AWS::KMS::Key',
66
+ // RDS — stateful only (DBInstance/Cluster + snapshots; NOT *Group, *Subscription, DBProxy)
67
+ 'AWS::RDS::DBInstance',
68
+ 'AWS::RDS::DBCluster',
69
+ 'AWS::RDS::GlobalCluster',
70
+ 'AWS::RDS::DBClusterSnapshot',
71
+ 'AWS::RDS::DBSnapshot',
72
+ // EFS — stateful only (FileSystem; NOT MountTarget, AccessPoint, FileSystemPolicy)
73
+ 'AWS::EFS::FileSystem',
74
+ // ElastiCache — "when persistent" per CLAUDE.md hard rule #4
75
+ // (NOT SubnetGroup, ParameterGroup, SecurityGroup, User, UserGroup)
76
+ 'AWS::ElastiCache::CacheCluster',
77
+ 'AWS::ElastiCache::ReplicationGroup',
78
+ 'AWS::ElastiCache::GlobalReplicationGroup',
79
+ 'AWS::ElastiCache::ServerlessCache',
80
+ ];
81
+
82
+ export function isDataResource(resourceType) {
83
+ if (!resourceType) return false;
84
+ return DATA_RESOURCE_EXACT.includes(resourceType);
85
+ }
@@ -0,0 +1,16 @@
1
+ // Built-in defaults for Clawform's consumer config. Merged FIRST (lowest precedence)
2
+ // by src/lib/project-config.js. A fresh consumer (no clawform.config.json) sees these
3
+ // values; the dogfood clawform.config.json in this repo overlays the Acme-specific bits.
4
+
5
+ export const DEFAULT_ENVS = ['d', 'stg', 'p'];
6
+
7
+ export const DEFAULT_CONFIG = {
8
+ customer: '',
9
+ envs: DEFAULT_ENVS,
10
+ region: '',
11
+ accounts: {},
12
+ // Empty by default — we never impose Acme's legacy list on someone else's account.
13
+ legacy_prefixes: [],
14
+ service_shortnames_extra: [],
15
+ tags_defaults: {},
16
+ };
@@ -0,0 +1,65 @@
1
+ // Machine fingerprint for license device-binding. The hash is what Clawform
2
+ // sends to Polar as an activation instance label, so one physical machine maps
3
+ // to one activation slot (see docs — device limit is per human machine; CI uses
4
+ // key-validate-only and never activates).
5
+ //
6
+ // The impure gathering (gatherMachineParts) is split from the pure hasher
7
+ // (computeFingerprint) so the hash is unit-testable without touching the OS —
8
+ // same pattern the rest of src/lib uses (pure core, injected I/O).
9
+
10
+ import { createHash } from 'node:crypto';
11
+ import os from 'node:os';
12
+ import { readFileSync as fsRead } from 'node:fs';
13
+
14
+ // Pure: given the raw identifying parts, return a stable short hex fingerprint.
15
+ // Order-fixed and NUL-delimited so two machines that merely share one field
16
+ // (e.g. platform) never collide, and so 'a'+'bc' can't alias 'ab'+'c'.
17
+ export function computeFingerprint({ hostname = '', platform = '', arch = '', machineId = '' } = {}) {
18
+ // '\u0000' as an escape, not a literal NUL byte — a raw control character in
19
+ // the source makes grep/diff treat the whole file as binary.
20
+ const material = [hostname, platform, arch, machineId].join('\u0000');
21
+ return createHash('sha256').update(material).digest('hex').slice(0, 32);
22
+ }
23
+
24
+ // Impure: gather the parts from the OS. machineId is best-effort — a stable
25
+ // per-install ID when the platform exposes one cheaply, else '' (the
26
+ // hostname/platform/arch trio still yields a usable, if slightly weaker,
27
+ // fingerprint). Never throws: any probe failure degrades to ''.
28
+ export function gatherMachineParts({ readFileSync = fsRead, osImpl = os } = {}) {
29
+ return {
30
+ hostname: safe(() => osImpl.hostname()),
31
+ platform: safe(() => osImpl.platform()),
32
+ arch: safe(() => osImpl.arch()),
33
+ machineId: readMachineId(readFileSync, osImpl),
34
+ };
35
+ }
36
+
37
+ // Convenience: gather + hash in one call. This is what `activate` uses.
38
+ export function machineFingerprint(deps = {}) {
39
+ return computeFingerprint(gatherMachineParts(deps));
40
+ }
41
+
42
+ function readMachineId(readFileSync, osImpl) {
43
+ const platform = safe(() => osImpl.platform());
44
+ // Linux exposes a stable per-install ID via systemd/dbus — cheap to read.
45
+ if (platform === 'linux') {
46
+ for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
47
+ const v = safe(() => readFileSync(p, 'utf8').trim());
48
+ if (v) return v;
49
+ }
50
+ }
51
+ // macOS/Windows machine-id needs a subprocess (ioreg / registry query). To
52
+ // keep this layer dependency-free and side-effect-light, fall back to '' and
53
+ // lean on hostname+platform+arch — good enough for a device deterrent at this
54
+ // price point. Revisit with node-machine-id only if real collisions appear.
55
+ return '';
56
+ }
57
+
58
+ function safe(fn) {
59
+ try {
60
+ const v = fn();
61
+ return typeof v === 'string' ? v : String(v ?? '');
62
+ } catch {
63
+ return '';
64
+ }
65
+ }
@@ -0,0 +1,28 @@
1
+ import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ // Claude Code has no "workflows" plugin component type — saved workflow
5
+ // scripts are discovered only from a project's own .claude/workflows/ (or
6
+ // ~/.claude/workflows/), never from a plugin's shipped files. This copies
7
+ // the plugin-shipped workflows/audit-all-stacks.js into the CONSUMER
8
+ // project's .claude/workflows/ so `/audit-all-stacks` (or `/workflows`)
9
+ // actually finds it there. Idempotent: never overwrites an existing copy,
10
+ // so a consumer's local customization survives repeated `clawform sync` runs.
11
+ export function ensureWorkflowInstalled({
12
+ repoRoot,
13
+ projectDir,
14
+ exists = existsSync,
15
+ mkdir = mkdirSync,
16
+ copyFile = copyFileSync,
17
+ } = {}) {
18
+ const src = join(repoRoot, 'workflows', 'audit-all-stacks.js');
19
+ if (!exists(src)) return { installed: false, reason: 'source-missing' };
20
+
21
+ const destDir = join(projectDir, '.claude', 'workflows');
22
+ const dest = join(destDir, 'audit-all-stacks.js');
23
+ if (exists(dest)) return { installed: false, reason: 'already-present', dest };
24
+
25
+ mkdir(destDir, { recursive: true });
26
+ copyFile(src, dest);
27
+ return { installed: true, dest };
28
+ }
@@ -0,0 +1,81 @@
1
+ // Where the plugin content kit comes from.
2
+ //
3
+ // It used to come from the npm tarball, alongside the key that opened it —
4
+ // which meant `npm i` handed a non-buyer the entire library. The kit now lives
5
+ // behind a licence check: the service verifies the key with the vendor and
6
+ // streams the bundle over TLS, so no key ships in the CLI at all.
7
+ //
8
+ // Fetched once and cached. After that the plugin can be re-assembled or
9
+ // repaired with no network, which matters because `init plugin` is not
10
+ // something a buyer should have to be online to re-run.
11
+
12
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { unpackFiles } from './payload.js';
15
+
16
+ export const KIT_CACHE_FILENAME = 'kit.bin';
17
+
18
+ // Said on every service failure. Someone hitting this may have a stack stuck
19
+ // mid-update, and the first thing they need to know is that the emergency path
20
+ // is not what just broke. Those two commands are never licence-gated, and they
21
+ // do not touch this module.
22
+ const STILL_WORKS = 'clawform deploy and clawform rollback are unaffected and keep working.';
23
+
24
+ function outage(reason) {
25
+ return new Error(
26
+ `Could not reach the Clawform kit service (${reason}). This is our side, not your licence. `
27
+ + `Try again shortly — ${STILL_WORKS}`,
28
+ );
29
+ }
30
+
31
+ function refusal(reason) {
32
+ return new Error(
33
+ `The kit service would not serve this licence (${reason}). `
34
+ + 'Run `clawform license` to see what this machine holds, or contact support@thejoseki.com.',
35
+ );
36
+ }
37
+
38
+ async function detailOf(resp) {
39
+ try {
40
+ const body = await resp.json();
41
+ return String(body?.detail ?? `HTTP ${resp.status}`);
42
+ } catch {
43
+ return `HTTP ${resp.status}`;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Resolve the content kit, from cache or from the service.
49
+ * @returns {Promise<Array<{path: string, content: string}>>}
50
+ */
51
+ export async function obtainKit({ licenseKey, serviceUrl, cacheDir, fetch: fetchImpl = fetch }) {
52
+ const cachePath = join(cacheDir, KIT_CACHE_FILENAME);
53
+ if (existsSync(cachePath)) return unpackFiles(readFileSync(cachePath));
54
+
55
+ let resp;
56
+ try {
57
+ resp = await fetchImpl(`${serviceUrl}/kit`, {
58
+ method: 'POST',
59
+ headers: { 'content-type': 'application/json' },
60
+ body: JSON.stringify({ license_key: licenseKey }),
61
+ });
62
+ } catch (err) {
63
+ throw outage(err?.message ?? 'network error');
64
+ }
65
+
66
+ // A throttle or a 5xx is the service failing, not the licence being judged —
67
+ // the same distinction the vendor client draws, and for the same reason.
68
+ if (resp.status === 429 || resp.status >= 500) throw outage(`HTTP ${resp.status}`);
69
+ if (!resp.ok) throw refusal(await detailOf(resp));
70
+
71
+ const bundle = Buffer.from(await resp.arrayBuffer());
72
+
73
+ // Unpack BEFORE caching. A truncated or mismatched bundle written to disk
74
+ // would be served as a real kit on every later run, turning one bad response
75
+ // into a permanently broken install with no obvious cause.
76
+ const entries = unpackFiles(bundle);
77
+
78
+ mkdirSync(cacheDir, { recursive: true });
79
+ writeFileSync(cachePath, bundle);
80
+ return entries;
81
+ }
@@ -0,0 +1,84 @@
1
+ // Vendor-agnostic license client seam. Every consumer talks to this contract:
2
+ //
3
+ // activate(key, fingerprint) → { status:'valid', instanceId, holder? }
4
+ // | { status:'invalid', reason } // limit_reached | expired | not_found
5
+ // | { status:'error', reason } // vendor unreachable
6
+ // validate(key, activationId?) → { status:'valid'|'invalid'|'error', reason? }
7
+ // deactivate(key, activationId)→ { status:'valid'|'invalid'|'error', reason? }
8
+ //
9
+ // resolveClient() picks the implementation from the license mode:
10
+ // 'off' (default) → a dormant stub (configured:false) — assertLicensed
11
+ // passes everything, so the repo's own dev/CI and every
12
+ // buyer before launch is unaffected.
13
+ // 'sandbox'/'live' → the real Polar adapter (configured:true) against the
14
+ // matching base URL.
15
+ //
16
+ // Flipping the shipped default to a live mode is the single reviewed decision
17
+ // that turns enforcement on (see license-config.js).
18
+
19
+ import { existsSync } from 'node:fs';
20
+ import { join, dirname } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { readEnv } from './rename-compat.js';
23
+ import { createPolarClient } from './providers/polar.js';
24
+ import {
25
+ LICENSE_MODE_DEFAULT,
26
+ DEV_MARKER,
27
+ POLAR_ORGANIZATION_ID,
28
+ POLAR_BENEFIT_ID,
29
+ POLAR_TRIAL_BENEFIT_ID,
30
+ POLAR_BASE_URL,
31
+ } from './license-config.js';
32
+
33
+ const DORMANT = {
34
+ configured: false,
35
+ async activate() { return { status: 'error', reason: 'license enforcement is off in this build' }; },
36
+ async validate() { return { status: 'error', reason: 'license enforcement is off in this build' }; },
37
+ async deactivate() { return { status: 'error', reason: 'license enforcement is off in this build' }; },
38
+ };
39
+
40
+ // Resolved from THIS module's location, never from process.cwd(): with cwd, a
41
+ // buyer running clawform inside any repo that happens to carry the dev marker
42
+ // would be handed the dormant client — a bypass needing no edit at all.
43
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
44
+
45
+ export function isSourceCheckout(packageRoot = PACKAGE_ROOT) {
46
+ return existsSync(join(packageRoot, DEV_MARKER));
47
+ }
48
+
49
+ // `env` stays POSITIONAL: callers pass an env object, and folding it into an
50
+ // options bag would silently reinterpret every existing call as `{env:
51
+ // undefined}` — falling back to process.env and passing for the wrong reason.
52
+ export function resolveMode(env = process.env, { packageRoot = PACKAGE_ROOT } = {}) {
53
+ const fromEnv = readEnv('LICENSE_MODE', env);
54
+ // The environment may pick BETWEEN enforcing modes; it can never turn
55
+ // enforcement down. Anything else — 'off', a typo, an empty string — is
56
+ // ignored rather than treated as a request to disable.
57
+ if (fromEnv === 'sandbox' || fromEnv === 'live') return fromEnv;
58
+ if (isSourceCheckout(packageRoot)) return 'off';
59
+ return LICENSE_MODE_DEFAULT;
60
+ }
61
+
62
+ // Which benefits are trials, for the mode this install is running in. Resolved
63
+ // here rather than in license-config because that is where mode resolution
64
+ // already lives, and a second copy of it would be free to disagree.
65
+ //
66
+ // A dormant install has no trials: nothing to claim, nothing to refuse.
67
+ export function resolveTrialBenefitIds({ env = process.env, packageRoot = PACKAGE_ROOT } = {}) {
68
+ const mode = resolveMode(env, { packageRoot });
69
+ return POLAR_TRIAL_BENEFIT_ID[mode] ?? [];
70
+ }
71
+
72
+ export function resolveClient({ env = process.env, packageRoot = PACKAGE_ROOT, fetch } = {}) {
73
+ const mode = resolveMode(env, { packageRoot });
74
+ if (mode !== 'sandbox' && mode !== 'live') return DORMANT;
75
+ // Both looked up by the same mode, so they cannot drift apart: a base URL
76
+ // for one environment with an organization from the other is the failure
77
+ // this shape exists to make impossible.
78
+ return createPolarClient({
79
+ baseUrl: POLAR_BASE_URL[mode],
80
+ organizationId: POLAR_ORGANIZATION_ID[mode],
81
+ benefitIds: POLAR_BENEFIT_ID[mode],
82
+ ...(fetch ? { fetch } : {}),
83
+ });
84
+ }
@@ -0,0 +1,162 @@
1
+ // Licensing time policy. Vendor identifiers (org/product IDs) join this file
2
+ // once the payout test settles which vendor we ship with — the time policy is
3
+ // vendor-independent and the grace/revalidate logic only needs these two.
4
+
5
+ // How long a cached "valid" verdict is trusted before the CLI re-checks with
6
+ // the vendor in the background. Once per day keeps revocation reasonably fresh
7
+ // without turning every command into a network call.
8
+ export const REVALIDATE_HOURS = 24;
9
+
10
+ // How long a buyer keeps working after the LAST successful validation when the
11
+ // vendor cannot be reached (offline dev box, vendor outage). Two weeks: long
12
+ // enough to survive a vacation offline, short enough that a revoked-but-
13
+ // unreachable key does not run forever.
14
+ export const GRACE_DAYS = 14;
15
+
16
+ // --- Vendor: Polar -------------------------------------------------------
17
+ // The organization id is not a secret — it is a public parameter of every
18
+ // customer-portal call, and the API needs no server token besides the buyer's
19
+ // own key. Safe to ship in the tarball.
20
+ //
21
+ // Keyed by mode, and it has to be. Polar's sandbox is a separate system rather
22
+ // than a flag on the same one: you sign up for it separately, the organization
23
+ // you create there does not exist in production, and its id is different. A
24
+ // single id shared across both modes builds a client that talks to
25
+ // api.polar.sh while naming a sandbox organization — every activation by a
26
+ // real buyer fails, and it fails on the first day of selling rather than in
27
+ // any amount of sandbox testing. Paired with POLAR_BASE_URL below so the two
28
+ // can only be changed together.
29
+ export const POLAR_ORGANIZATION_ID = {
30
+ live: '75b98aba-8768-4534-9b76-dd7f16cba3d3',
31
+ sandbox: 'c234fc61-20e4-4d6f-b919-0f5823f7984d',
32
+ };
33
+ export const POLAR_BASE_URL = {
34
+ live: 'https://api.polar.sh',
35
+ sandbox: 'https://sandbox-api.polar.sh',
36
+ };
37
+
38
+ // The License Keys benefit this product sells. The organization is not enough
39
+ // to identify a key: every product under the same organization validates
40
+ // against the same endpoint with the same organization id, so the moment a
41
+ // second product sells from here, a key bought for THAT product activates this
42
+ // one. A paying customer of one tool silently unlocking another is not a bug
43
+ // anyone reports.
44
+ //
45
+ // Polar returns the benefit on both activate and validate, so the check costs
46
+ // nothing and closes it. Keyed by mode for the same reason the organization is
47
+ // — a sandbox benefit does not exist in production.
48
+ //
49
+ // A LIST per mode, one entry per paid tier. Polar carries limit_activations on
50
+ // the benefit, not the product, so tiers with different device caps cannot share
51
+ // one — the individual seat and the team seat are necessarily separate benefits.
52
+ // A key is accepted when it matches ANY entry; the tier it belongs to is Polar's
53
+ // business, not the CLI's.
54
+ //
55
+ // Nothing may appear in both lists: an id reachable from either environment
56
+ // would let a sandbox key unlock a live install.
57
+ export const POLAR_BENEFIT_ID = {
58
+ // [0] individual seat, [1] team seat. Order is not significance — a key is
59
+ // accepted on any match — but it is the order the walk tries, so the tier
60
+ // that sells most often belongs first.
61
+ live: [
62
+ 'b85a9271-22ac-45b3-ab0a-b0c93df6b13c',
63
+ 'be046860-7be4-43bd-ac8b-cf47d08f77d0',
64
+ 'a3fc4e93-9e93-4317-bed5-5b44a5af9e19',
65
+ ],
66
+ sandbox: [
67
+ '69d1ccaa-65c4-48da-ad4a-64961772c17e',
68
+ 'a540043d-da17-48ae-bee6-e9a900fbe1cc',
69
+ '9c5fe82d-09a7-490c-91e3-47d164603423',
70
+ ],
71
+ };
72
+
73
+ // Which of the benefits above are TRIALS. A trial key validates like any other
74
+ // — it has to appear in POLAR_BENEFIT_ID as well — but it additionally claims
75
+ // the machine, so one device cannot start a second trial under a new email.
76
+ //
77
+ // Listed separately rather than encoded in the entries above so phase 01's
78
+ // shape and its tests stay untouched. A test pins that every id here also
79
+ // appears in POLAR_BENEFIT_ID: an id in only one list is a trial key nobody can
80
+ // activate, or a paid key that silently burns a device claim.
81
+ //
82
+ // The licence-key benefit on each trial product must be VISIBLE in Polar: the
83
+ // buyer has to read the key out of the portal or the confirmation email to type
84
+ // it into `clawform activate`. A hidden benefit is granted and unusable.
85
+ export const POLAR_TRIAL_BENEFIT_ID = {
86
+ live: ['a3fc4e93-9e93-4317-bed5-5b44a5af9e19'],
87
+ sandbox: ['9c5fe82d-09a7-490c-91e3-47d164603423'],
88
+ };
89
+
90
+ // --- Content kit delivery -----------------------------------------------
91
+ // The plugin content (skills/commands/agents/rules/templates) is NOT in the npm
92
+ // tarball. It is fetched by `clawform init plugin` from this service, which
93
+ // checks the licence with the vendor before serving anything.
94
+ //
95
+ // The previous model shipped the content as an encrypted blob with its key
96
+ // beside it in the same tarball — which protected nothing: `npm i` handed a
97
+ // non-buyer both halves. There is no key here now because the CLI never
98
+ // decrypts; the bundle lives in a private bucket and travels over TLS.
99
+ //
100
+ // Not keyed by licence mode. There is one kit, and which licences may have it
101
+ // is the service's judgement, not a build-time constant.
102
+ export const KIT_SERVICE_URL = 'https://kit.thejoseki.com';
103
+
104
+ // Written by scripts/pack-payload.js for upload, and cached under the vendor
105
+ // home after a successful fetch. Never present in the published package.
106
+ export const KIT_FILENAME = 'kit.bin';
107
+
108
+ // Signs the local licence record. The gate trusts an 'active' record with a
109
+ // fresh timestamp without calling the vendor, so an unsigned cache would make
110
+ // `echo '{"status":"active",…}' > ~/.clawform/license.json` a complete bypass —
111
+ // and a shareable one-liner at that. Signing raises it to "extract this secret
112
+ // from the CLI first", the same bar as the dev marker.
113
+ //
114
+ // Same 1a honesty as PAYLOAD_KEY_HEX: the secret ships with the code, so a
115
+ // determined attacker can still forge. The goal is killing the one-liner class.
116
+ // Distinct from the payload key on purpose — keys with different jobs.
117
+ //
118
+ // Rotates alongside the payload key, and for the same reason: a release that
119
+ // published this value hands every later release a forged 'active' record.
120
+ // The cost is that records signed with the old secret read as unlicensed, so a
121
+ // buyer re-activates — cheap now, and it only gets dearer with every seat sold.
122
+ export const LICENSE_RECORD_SECRET_HEX = 'c4179ff7c3e2ebd223fdb2653ed1163f5b8cb89c7900af2a88f186fbb55818e5';
123
+
124
+ // Tolerance for a machine clock running slightly ahead of the vendor's. Beyond
125
+ // it, a future `lastValidatedAt` is treated as needing revalidation rather than
126
+ // as an eternally fresh verdict.
127
+ export const CLOCK_SKEW_HOURS = 1;
128
+
129
+ // How a build tells whether it is a source checkout or an installed package.
130
+ // This file is the payload build script — `package.json` `files` never ships
131
+ // `scripts/`, so it is present in a clone and absent in `npm i -g @thejoseki/clawform`.
132
+ //
133
+ // Dormancy is granted by the install SHAPE, not by anything a buyer can set:
134
+ // a `CLAWFORM_LICENSE_MODE=off` that disabled the gate would be a
135
+ // copy-pasteable one-liner, the one bypass class that actually spreads.
136
+ // Faking dormancy now means creating this file inside the installed package —
137
+ // an edit, not a one-liner.
138
+ //
139
+ // ⚠️ Adding `scripts/` to `package.json` `files` would ship the marker and
140
+ // silently disable enforcement for everyone. A packaging test asserts it stays
141
+ // out of the tarball.
142
+ export const DEV_MARKER = 'scripts/pack-payload.js';
143
+
144
+ // The master switch, and the single decision that turns enforcement on.
145
+ //
146
+ // Held at 'off' through development, because enforcing before a key could be
147
+ // bought would have locked out every user with no way back in. It moved to
148
+ // 'live' only once all three preconditions were verified against the
149
+ // production vendor rather than assumed:
150
+ //
151
+ // - a Polar product exists with a License Keys benefit, activation limit 3
152
+ // - a real purchased key drives activate / validate / deactivate, the
153
+ // three-device cap refuses a fourth machine, and deactivate demonstrably
154
+ // frees a slot (proved by re-activating, not by the return code)
155
+ // - the sandbox and production environments reject each other's keys, which
156
+ // is what makes the per-mode organization and benefit ids load-bearing
157
+ //
158
+ // A source checkout stays dormant regardless: that exemption comes from the
159
+ // dev marker's presence, not from this value, so contributors need no key.
160
+ // Overridable per run via CLAWFORM_LICENSE_MODE, which can raise enforcement
161
+ // but never lower it.
162
+ export const LICENSE_MODE_DEFAULT = 'live';
@@ -0,0 +1,107 @@
1
+ // Local license cache — ~/.clawform/license.json. This is per-MACHINE / per-user
2
+ // state (unlike the per-project .clawform-state.json in session-state.js), so it
3
+ // lives in the home dir, not the project. It records what license enforcement
4
+ // needs between runs: the key, its Polar activation instance, when it was last
5
+ // validated, and who holds it. It never stores a Polar API secret — validation
6
+ // uses the customer's key alone, there is no server-side credential in the CLI.
7
+ //
8
+ // All reads fail-open: a missing or corrupt file yields an 'unlicensed' state,
9
+ // never an exception, so a bad cache can never crash a command (same discipline
10
+ // as session-state.js). Writes create ~/.clawform/ on demand.
11
+ //
12
+ // Stored record shape (all optional except v/status):
13
+ // { v, status: 'active'|'unlicensed', key, instanceId, holder,
14
+ // fingerprint, lastValidatedAt (ISO string) }
15
+
16
+ import {
17
+ readFileSync as fsRead,
18
+ writeFileSync as fsWrite,
19
+ mkdirSync as fsMkdir,
20
+ rmSync as fsRm,
21
+ } from 'node:fs';
22
+ import { createHmac, timingSafeEqual } from 'node:crypto';
23
+ import { homedir as osHomedir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ import { LICENSE_RECORD_SECRET_HEX } from './license-config.js';
26
+
27
+ export const LICENSE_DIR = '.clawform';
28
+ export const LICENSE_FILENAME = 'license.json';
29
+ export const LICENSE_VERSION = 1;
30
+
31
+ function licenseDir(home) { return join(home, LICENSE_DIR); }
32
+ function licensePath(home) { return join(licenseDir(home), LICENSE_FILENAME); }
33
+
34
+ function unlicensed() { return { v: LICENSE_VERSION, status: 'unlicensed' }; }
35
+
36
+ // Fields that a signature covers. Fixed order, so the digest depends on the
37
+ // content and not on the order the caller happened to build the object in.
38
+ //
39
+ // `expiresAt` is here for the same reason `lastValidatedAt` is: a trial's end
40
+ // date that a text editor can push out is not an end date. Adding a field
41
+ // changes every existing signature, so records written before this read as
42
+ // unlicensed and their owner re-activates once — cheap while the seat count is
43
+ // small, and dearer with every seat sold.
44
+ const SIGNED_FIELDS = ['v', 'status', 'key', 'instanceId', 'holder', 'fingerprint', 'lastValidatedAt', 'expiresAt'];
45
+
46
+ function signatureOf(record) {
47
+ const canonical = JSON.stringify(SIGNED_FIELDS.map((f) => record[f] ?? null));
48
+ return createHmac('sha256', Buffer.from(LICENSE_RECORD_SECRET_HEX, 'hex'))
49
+ .update(canonical)
50
+ .digest('hex');
51
+ }
52
+
53
+ // Only an 'active' record makes a claim worth forging, so only that one has to
54
+ // prove itself. Timing-safe compare keeps the check from leaking the expected
55
+ // digest byte by byte.
56
+ function signatureValid(record) {
57
+ const expected = Buffer.from(signatureOf(record), 'utf8');
58
+ const actual = Buffer.from(String(record.sig ?? ''), 'utf8');
59
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
60
+ }
61
+
62
+ // Read the cached license. Fail-open on every error path: missing file, bad
63
+ // JSON, wrong version, or a shape that isn't an object → 'unlicensed'.
64
+ export function readLicense({ homedir = osHomedir, readFileSync = fsRead } = {}) {
65
+ let raw;
66
+ try {
67
+ raw = readFileSync(licensePath(homedir()), 'utf8');
68
+ } catch {
69
+ return unlicensed();
70
+ }
71
+ try {
72
+ const parsed = JSON.parse(raw);
73
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return unlicensed();
74
+ if (parsed.v !== LICENSE_VERSION || typeof parsed.status !== 'string') return unlicensed();
75
+ // An unsigned or altered 'active' record is not a licence — it is exactly
76
+ // the forgery the signature exists to catch. 'unlicensed' claims nothing,
77
+ // so it needs no proof.
78
+ if (parsed.status === 'active' && !signatureValid(parsed)) return unlicensed();
79
+ return parsed;
80
+ } catch {
81
+ return unlicensed();
82
+ }
83
+ }
84
+
85
+ // Persist a license record, creating ~/.clawform/ if needed. The version is
86
+ // always stamped by us, so callers pass only the payload fields.
87
+ export function writeLicense(record, {
88
+ homedir = osHomedir,
89
+ writeFileSync = fsWrite,
90
+ mkdirSync = fsMkdir,
91
+ } = {}) {
92
+ const home = homedir();
93
+ mkdirSync(licenseDir(home), { recursive: true });
94
+ const out = { ...record, v: LICENSE_VERSION };
95
+ out.sig = signatureOf(out);
96
+ writeFileSync(licensePath(home), JSON.stringify(out, null, 2));
97
+ return out;
98
+ }
99
+
100
+ // Remove the cache (used by `deactivate`). Fail-open: a missing file is success.
101
+ export function clearLicense({ homedir = osHomedir, rmSync = fsRm } = {}) {
102
+ try {
103
+ rmSync(licensePath(homedir()), { force: true });
104
+ } catch {
105
+ // fail-open — nothing to clear, or an unwritable path we can't help here.
106
+ }
107
+ }