Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
101 changes: 101 additions & 0 deletions packages/sdk/src/modules/core/custom-broadcast-usage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";

/**
* `auth.broadcast` is the caller-supplied broadcaster, not "the keychain path".
*
* Four mutations read it as though it were the latter: they checked
* `auth?.broadcast` and threw when it was missing. Every field on `AuthContext`
* is optional, `broadcast?` included, so an `AuthContextV2` satisfies the type
* structurally and the check compiled fine everywhere. Each site then failed
* only when a real user reached it, which is how four accumulated before one
* surfaced as a Sentry issue.
*
* The type system cannot express this, so a scan is what is left. It reads the
* syntax tree rather than the text, because `auth.broadcast` appears in prose
* throughout these files and a text search reports the comments explaining why
* not to use it.
*/

const MODULES = join(__dirname, "..");

/**
* Where reading `auth.broadcast` is the point rather than a mistake.
*
* `use-broadcast-mutation` owns the `custom` branch of the fallback chain, which
* is the whole feature. Anything else added here is an admission, so it needs a
* reason next to it.
*/
const SANCTIONED: Record<string, string> = {
"core/mutations/use-broadcast-mutation.ts":
"owns case 'custom', the last link of the default fallback chain",
"core/mutations/broadcast-json.ts":
"first branch of its own fallback chain, kept so V1 callers still work",
};

function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
if (statSync(path).isDirectory()) return sourceFiles(path);
return /\.ts$/.test(path) && !/\.spec\.ts$/.test(path) ? [path] : [];
});
}

/** True when the file reads `.broadcast` off something named like an auth ctx. */
function readsAuthBroadcast(file: string): boolean {
const sf = ts.createSourceFile(
file,
readFileSync(file, "utf8"),
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
);

let found = false;
const visit = (node: ts.Node): void => {
if (
ts.isPropertyAccessExpression(node) &&
node.name.text === "broadcast" &&
/^auth$/i.test(node.expression.getText(sf).replace(/[?!]/g, ""))
) {
found = true;
}
ts.forEachChild(node, visit);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
visit(sf);
return found;
}

describe("auth.broadcast is only read where it is the feature", () => {
const files = sourceFiles(MODULES);

it("finds the modules to scan", () => {
// Guards the reader: an empty list would make the sweep vacuous.
expect(files.length).toBeGreaterThan(50);
});

it("is read only in sanctioned places", () => {
const offenders = files
.filter(readsAuthBroadcast)
.map((f) => f.slice(MODULES.length + 1).split("\\").join("/"))
.filter((rel) => !(rel in SANCTIONED));

expect(offenders).toEqual([]);
});

/**
* The detector has to detect. A scan that silently matches nothing passes
* exactly as well as one that works, which is the failure mode this whole
* class of test invites.
*/
it("would catch a new reader, and ignores prose", () => {
const sanctioned = join(MODULES, "core/mutations/use-broadcast-mutation.ts");
expect(readsAuthBroadcast(sanctioned)).toBe(true);

// A file that only MENTIONS it in a comment must not register.
const proseOnly = join(MODULES, "core/types/auth.ts");
expect(readsAuthBroadcast(proseOnly)).toBe(false);
});
});
24 changes: 22 additions & 2 deletions packages/sdk/src/modules/core/types/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,28 @@ export interface AuthContext {
/** Login method used ('key', 'hivesigner', 'keychain', 'hiveauth') */
loginType?: string | null;
/**
* Custom broadcast function for platform-specific signing.
* @deprecated Use platform adapter's broadcastWithKeychain/broadcastWithHiveAuth instead.
* A caller-supplied broadcaster, for signing the platform adapter cannot do.
*
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
* NOT deprecated, and not a legacy path. `useBroadcastMutation` reaches it as
* `case 'custom'`, the last link of the default fallback chain, and two call
* sites in the web app need it because no adapter method fits:
*
* - `use-login-by-key.ts` grants posting permission DURING login, before the
* user exists to the adapter, so the key comes from a ref instead.
* - `wallet-operations-sign.tsx` dispatches on a signing method the user picks
* mid-flow, which is a decision the adapter has no way to know about.
*
* It carried an `@deprecated` tag pointing at `broadcastWithKeychain`, and
* that reading is what went wrong: four SDK mutations treated this as "the
* keychain path", checked it, and threw when a caller passed an
* `AuthContextV2` that legitimately has no `broadcast`. Every field here is
* optional, so V2 satisfies `AuthContext` structurally and the type checker
* saw nothing; each site failed only when a real user reached it. Migrated in
* #1376.
*
* So: reach for the adapter when you mean "sign with the user's wallet". Reach
* for this only when you are supplying the signing yourself, and never as a
* way to detect Keychain.
*/
broadcast?: (
operations: Operation[],
Expand Down
Loading