@cosmicdrift/kumiko-guards 0.320.0 → 0.322.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.320.0",
3
+ "version": "0.322.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -28,7 +28,7 @@
28
28
  "kumiko-pre-push": "./src/pre-push.sh"
29
29
  },
30
30
  "dependencies": {
31
- "@cosmicdrift/kumiko-repo-manifest": "0.320.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.322.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
package/src/changes.json CHANGED
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "0.321.0",
4
+ "type": "improvement",
5
+ "title": "pre-push.sh refuses to push a stale .kumiko/upgrade-state.json",
6
+ "detail": "When `.kumiko/upgrade-state.json` exists at the repo root, `pre-push.sh`\nnow runs the upgrade-state guard before its main check and refuses the\npush if the guard fails, resolving `guard-upgrade-state.ts` next to the\nhook's real (symlink-resolved) script location.",
7
+ "migration": "A repo that has adopted `.kumiko/upgrade-state.json` and is currently\nstale now has its push blocked until the upgrade state is reconciled. A\nrepo without that marker file is unaffected."
8
+ },
9
+ {
10
+ "version": "0.321.0",
11
+ "type": "improvement",
12
+ "title": "New guard blocks direct writes to the event-store tables outside the event store itself",
13
+ "detail": "guard-event-store-writes.ts flags any UPDATE/DELETE FROM/INSERT INTO\nstring or template literal naming kumiko_events, kumiko_snapshots, or\nkumiko_archived_streams outside packages/framework/src/event-store/**\nand their db/queries/event-store*.ts backing (plus the two pre-existing,\nnamed one-time backfill exceptions). Registered in run-guards.ts.",
14
+ "migration": "No action needed for code that already goes through the event store's\nown primitives (append, transferAggregateStreams, archiveStream, ...).\nA direct raw-SQL write against one of these three tables from feature\ncode now fails the guard; use transferAggregateStreams for a tenant\nmove, or add a new event-store primitive instead."
15
+ },
2
16
  {
3
17
  "version": "0.317.0",
4
18
  "type": "fix",
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: flags string/template literals containing UPDATE/DELETE FROM/INSERT
4
+ * INTO against kumiko_events, kumiko_snapshots or kumiko_archived_streams
5
+ * outside the event store's own files.
6
+ *
7
+ * These three tables ARE the event store. A direct write from elsewhere
8
+ * bypasses its invariants (version-unique index, archive markers, tenant
9
+ * boundary). Tenant moves go through `transferAggregateStreams`
10
+ * (event-store/transfer.ts); any other need belongs in
11
+ * db/queries/event-store*.ts.
12
+ *
13
+ * Usage:
14
+ * bun packages/guards/src/guard-event-store-writes.ts
15
+ */
16
+
17
+ import { type SourceFile, SyntaxKind } from "ts-morph";
18
+ import {
19
+ type AstGuard,
20
+ type GuardViolation,
21
+ relFromRepoRoot,
22
+ runStandalone,
23
+ type ScanSpec,
24
+ } from "./_lib/guard-kit";
25
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
26
+
27
+ const SCAN: ScanSpec = { scope: "source", extensions: ["ts"] };
28
+
29
+ // Tests fabricate their own fixture rows directly — they are the primary
30
+ // verifiers of the event-store primitives, not a write surface this guard
31
+ // needs to police.
32
+ const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
33
+
34
+ // Files allowed to write these three tables directly:
35
+ // - event-store/** — the primitive's own home (transfer.ts, event-store.ts,
36
+ // snapshot.ts, archive.ts, admin-api.ts, rebuild-dead-letter.ts, ...).
37
+ // - db/queries/event-store*.ts — the raw-SQL backing queries the primitives
38
+ // call (event-store.ts, event-store-admin.ts, event-store-transfer.ts).
39
+ // - db/queries/backfill-pii.ts — the one-time pre-KMS plaintext backfill;
40
+ // rewrites payload/deletes stale snapshots in place, framework-owned.
41
+ // - user/db/queries/stream-tenant-backfill.ts — a one-time stream-ownership
42
+ // migration that predates this guard; already carries its own
43
+ // RAW_SQL_ALLOWLIST entry, kept here for the same reason.
44
+ const ALLOWLIST: readonly RegExp[] = [
45
+ /^packages\/framework\/src\/event-store\//,
46
+ /^packages\/framework\/src\/db\/queries\/event-store.*\.ts$/,
47
+ /^packages\/framework\/src\/db\/queries\/backfill-pii\.ts$/,
48
+ /^packages\/bundled-features\/src\/user\/db\/queries\/stream-tenant-backfill\.ts$/,
49
+ ];
50
+
51
+ const ES_TABLES = "kumiko_events|kumiko_snapshots|kumiko_archived_streams";
52
+ // One string/template literal must carry both the write verb AND the table
53
+ // name — a keyword in one concatenated piece and the table name (behind a
54
+ // variable) in another is indirection this guard does not see through; the
55
+ // event store's own queries are the only place that literal belongs.
56
+ const WRITE_RE = new RegExp(
57
+ `\\b(UPDATE|DELETE\\s+FROM|INSERT\\s+INTO)\\b[\\s\\S]*?["'\`]?(${ES_TABLES})["'\`]?`,
58
+ "i",
59
+ );
60
+
61
+ const LITERAL_KINDS = [
62
+ SyntaxKind.StringLiteral,
63
+ SyntaxKind.NoSubstitutionTemplateLiteral,
64
+ SyntaxKind.TemplateExpression,
65
+ ];
66
+
67
+ export type Violation = {
68
+ readonly file: string;
69
+ readonly line: number;
70
+ readonly snippet: string;
71
+ };
72
+
73
+ export function isAllowed(relativePath: string): boolean {
74
+ return ALLOWLIST.some((re) => re.test(relativePath));
75
+ }
76
+
77
+ export function collectViolations(
78
+ sourceFile: SourceFile,
79
+ roots: readonly RepoRoot[] = resolveRepoRoots(),
80
+ ): Violation[] {
81
+ const violations: Violation[] = [];
82
+ // Repo-root-relative, not `process.cwd()`-relative — a multi-repo runner
83
+ // (e.g. invoked from the parent workspace dir) would otherwise carry a
84
+ // leading `kumiko-framework/` segment that the ALLOWLIST regexes (anchored
85
+ // on `^packages/...`) never match, wrongly flagging every allowed file.
86
+ const relativePath = relFromRepoRoot(sourceFile.getFilePath(), roots);
87
+ if (isAllowed(relativePath)) return violations;
88
+
89
+ for (const kind of LITERAL_KINDS) {
90
+ for (const node of sourceFile.getDescendantsOfKind(kind)) {
91
+ const text = node.getText();
92
+ const match = WRITE_RE.exec(text);
93
+ if (!match) continue;
94
+ violations.push({
95
+ file: relativePath,
96
+ line: node.getStartLineNumber(),
97
+ snippet: match[0].replace(/\s+/g, " ").trim().slice(0, 120),
98
+ });
99
+ }
100
+ }
101
+ return violations;
102
+ }
103
+
104
+ export const guard: AstGuard = {
105
+ name: "Event-Store-Writes Guard",
106
+ scan: SCAN,
107
+ hint:
108
+ "kumiko_events/kumiko_snapshots/kumiko_archived_streams may only be written from " +
109
+ "packages/framework/src/event-store/** and their db/queries/event-store*.ts backing. " +
110
+ "Use transferAggregateStreams (event-store/transfer.ts) for a tenant move, or add a " +
111
+ "new event-store primitive instead of writing these tables from feature code.",
112
+ run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
113
+ const violations: GuardViolation[] = [];
114
+ for (const sf of files) {
115
+ if (EXCLUDE.test(sf.getFilePath())) continue;
116
+ for (const v of collectViolations(sf, roots)) {
117
+ violations.push({ file: v.file, line: v.line, message: v.snippet });
118
+ }
119
+ }
120
+ return { violations };
121
+ },
122
+ };
123
+
124
+ if (import.meta.main) runStandalone(guard);
@@ -126,6 +126,11 @@ const ALLOWLIST: readonly AllowEntry[] = [
126
126
  { repo: "kumiko-enterprise", pattern: /^packages\/publish\/src\/build\.ts$/ },
127
127
  { repo: "kumiko-enterprise", pattern: /^packages\/publish\/src\/feature\.ts$/ },
128
128
  { repo: "kumiko-enterprise", pattern: /^packages\/publish\/src\/validate\.ts$/ },
129
+
130
+ // testing: `kumiko-testing integration` CLI resolves positional test-file
131
+ // args against the developer's cwd, local dev/CI tooling reading paths
132
+ // the developer typed — no server request input.
133
+ { repo: "kumiko-framework", pattern: /^packages\/testing\/src\/integration-runner\.ts$/ },
129
134
  ];
130
135
 
131
136
  export function isRepoAllowlisted(
package/src/pre-push.sh CHANGED
@@ -86,6 +86,37 @@ if git -C "$REPO_ROOT" ls-files --error-unmatch scripts/pre-push-extra.sh >/dev/
86
86
  (cd "$REPO_ROOT" && KUMIKO_PUSH_REPO_ROOT="$REPO_ROOT" KUMIKO_PUSH_PARENT_DIR="$PARENT_DIR" "$REPO_ROOT/scripts/pre-push-extra.sh" "$@") || exit 1
87
87
  fi
88
88
 
89
+ # Upgrade-state guard: a repo that has adopted the `.kumiko/upgrade-state.json`
90
+ # marker must not push with it stale. Installed as a bin, `$0` here is the
91
+ # node_modules/.bin symlink, not this file's real path — resolve the symlink
92
+ # chain so guard-upgrade-state.ts is found next to the real script, not $0.
93
+ resolve_script_dir() {
94
+ SOURCE="$1"
95
+ while [ -h "$SOURCE" ]; do
96
+ DIR="$(cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd)"
97
+ SOURCE="$(readlink "$SOURCE")"
98
+ case "$SOURCE" in
99
+ /*) ;;
100
+ *) SOURCE="$DIR/$SOURCE" ;;
101
+ esac
102
+ done
103
+ cd -P "$(dirname "$SOURCE")" >/dev/null 2>&1 && pwd
104
+ }
105
+
106
+ if [ -f "$REPO_ROOT/.kumiko/upgrade-state.json" ]; then
107
+ SCRIPT_DIR="$(resolve_script_dir "$0")"
108
+ GUARD_UPGRADE_STATE="$SCRIPT_DIR/guard-upgrade-state.ts"
109
+ if [ ! -f "$GUARD_UPGRADE_STATE" ]; then
110
+ echo "[pre-push] FATAL: guard-upgrade-state.ts not found next to resolved script location '$SCRIPT_DIR' — refusing push" >&2
111
+ exit 1
112
+ fi
113
+ echo "[pre-push] .kumiko/upgrade-state.json present — running upgrade-state guard…"
114
+ if ! (cd "$REPO_ROOT" && bun "$GUARD_UPGRADE_STATE"); then
115
+ echo "[pre-push] upgrade-state guard failed — refusing push" >&2
116
+ exit 1
117
+ fi
118
+ fi
119
+
89
120
  # infra#559/#569: from a worktree, `--git-common-dir` resolves to the main
90
121
  # checkout's .git, so the branch below would scope `bun check` to the
91
122
  # canonical sibling path instead of this worktree — checking the wrong
package/src/run-guards.ts CHANGED
@@ -31,6 +31,7 @@ import { guard as directEntityWrites } from "./guard-direct-entity-writes";
31
31
  import { guard as directFetch } from "./guard-direct-fetch";
32
32
  import { guard as errorReasons } from "./guard-error-reasons";
33
33
  import { guard as escapeHatchDeclared } from "./guard-escape-hatch-declared";
34
+ import { guard as eventStoreWrites } from "./guard-event-store-writes";
34
35
  import { guard as fakeTests } from "./guard-fake-tests";
35
36
  import { guard as htmlEscape } from "./guard-html-escape";
36
37
  import { guard as i18nKeys } from "./guard-i18n-keys";
@@ -88,6 +89,7 @@ export const GUARDS = [
88
89
  writeHandlerQns,
89
90
  asCasts,
90
91
  loadallEvents,
92
+ eventStoreWrites,
91
93
  tableDdl,
92
94
  appFeatureStructure,
93
95
  libTestCoverage,