@reposkein/mcp 0.4.0 → 0.6.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/README.md +6 -1
- package/binary-digests.json +4 -4
- package/dist/ads/auditLog.d.ts +23 -0
- package/dist/ads/auditLog.js +33 -0
- package/dist/ads/auditLog.js.map +1 -0
- package/dist/ads/config.d.ts +83 -0
- package/dist/ads/config.js +107 -0
- package/dist/ads/config.js.map +1 -0
- package/dist/ads/luluSource.d.ts +32 -0
- package/dist/ads/luluSource.js +60 -0
- package/dist/ads/luluSource.js.map +1 -0
- package/dist/ads/sanitize.d.ts +16 -0
- package/dist/ads/sanitize.js +187 -0
- package/dist/ads/sanitize.js.map +1 -0
- package/dist/ads/slot.d.ts +63 -0
- package/dist/ads/slot.js +159 -0
- package/dist/ads/slot.js.map +1 -0
- package/dist/ads/supporter.d.ts +73 -0
- package/dist/ads/supporter.js +119 -0
- package/dist/ads/supporter.js.map +1 -0
- package/dist/ads/supporterKey.d.ts +45 -0
- package/dist/ads/supporterKey.js +51 -0
- package/dist/ads/supporterKey.js.map +1 -0
- package/dist/ads/supporterStore.d.ts +61 -0
- package/dist/ads/supporterStore.js +113 -0
- package/dist/ads/supporterStore.js.map +1 -0
- package/dist/ads/supporterToken.d.ts +133 -0
- package/dist/ads/supporterToken.js +233 -0
- package/dist/ads/supporterToken.js.map +1 -0
- package/dist/ads/types.d.ts +53 -0
- package/dist/ads/types.js +15 -0
- package/dist/ads/types.js.map +1 -0
- package/dist/cli/support.d.ts +46 -0
- package/dist/cli/support.js +276 -0
- package/dist/cli/support.js.map +1 -0
- package/dist/cli/view.js +27 -6
- package/dist/cli/view.js.map +1 -1
- package/dist/index.d.ts +8 -56
- package/dist/index.js +45 -402
- package/dist/index.js.map +1 -1
- package/dist/indexer/indexLock.d.ts +13 -0
- package/dist/indexer/indexLock.js +66 -0
- package/dist/indexer/indexLock.js.map +1 -0
- package/dist/indexer/runIndexer.d.ts +8 -0
- package/dist/indexer/runIndexer.js +12 -0
- package/dist/indexer/runIndexer.js.map +1 -1
- package/dist/serve/serve.d.ts +95 -0
- package/dist/serve/serve.js +581 -0
- package/dist/serve/serve.js.map +1 -0
- package/dist/serve/tokens.d.ts +49 -0
- package/dist/serve/tokens.js +108 -0
- package/dist/serve/tokens.js.map +1 -0
- package/dist/serve/watch.d.ts +73 -0
- package/dist/serve/watch.js +135 -0
- package/dist/serve/watch.js.map +1 -0
- package/dist/server/createMcpServer.d.ts +134 -0
- package/dist/server/createMcpServer.js +473 -0
- package/dist/server/createMcpServer.js.map +1 -0
- package/dist/store/JsonlGraphStore.d.ts +5 -1
- package/dist/store/JsonlGraphStore.js +6 -2
- package/dist/store/JsonlGraphStore.js.map +1 -1
- package/dist/store/teamConfig.d.ts +20 -6
- package/dist/store/teamConfig.js +70 -17
- package/dist/store/teamConfig.js.map +1 -1
- package/dist/tools/recordDecision.d.ts +3 -0
- package/dist/tools/recordDecision.js +1 -1
- package/dist/tools/recordDecision.js.map +1 -1
- package/dist/tools/writeSemanticSummary.d.ts +4 -1
- package/dist/tools/writeSemanticSummary.js +5 -2
- package/dist/tools/writeSemanticSummary.js.map +1 -1
- package/dist/viz/assets/index-BnqIVYFE.css +1 -0
- package/dist/viz/assets/index-IhtszTp0.js +5 -0
- package/dist/viz/assets/{r3f-BZY3QR6_.js → r3f-Bn9htB92.js} +1 -1
- package/dist/viz/assets/{tanstack-CjXr-L-s.js → tanstack-CSzx6NDA.js} +7 -7
- package/dist/viz/index.html +4 -4
- package/package.json +3 -2
- package/dist/viz/assets/index-CxqeGuRS.css +0 -1
- package/dist/viz/assets/index-DiNZyrXx.js +0 -9
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Where a supporter token lives on disk, and the only code that puts it
|
|
2
|
+
* there.
|
|
3
|
+
*
|
|
4
|
+
* ## User-level, never repo-level
|
|
5
|
+
*
|
|
6
|
+
* Entitlement belongs to a PERSON, not to a checkout. Storing it under
|
|
7
|
+
* `.reposkein/` would be wrong three times over: it would ride into git (or
|
|
8
|
+
* need yet another ignore rule to stop it), it would be shared with everyone
|
|
9
|
+
* who clones the repo — handing them a token they did not pay for — and it
|
|
10
|
+
* would have to be re-supplied in every working copy by the one person who
|
|
11
|
+
* did. So it lives under the user's config directory and nothing in this
|
|
12
|
+
* package ever writes an entitlement byte inside a repository. There is a
|
|
13
|
+
* test that asserts exactly that by byte-comparing a repo tree across a
|
|
14
|
+
* `support` invocation.
|
|
15
|
+
*
|
|
16
|
+
* ## Permissions
|
|
17
|
+
*
|
|
18
|
+
* Directory 0700, file 0600, and the file mode is re-applied with an
|
|
19
|
+
* explicit `chmodSync` after every write: `writeFileSync`'s `mode` option is
|
|
20
|
+
* honoured only when the file is CREATED, so overwriting an existing
|
|
21
|
+
* world-readable token would otherwise silently leave it world-readable.
|
|
22
|
+
* The token is not a password — the worst a thief gets is no ads — but a
|
|
23
|
+
* paid entitlement is still the holder's to keep, and 0600 costs nothing.
|
|
24
|
+
*
|
|
25
|
+
* On Windows these modes are approximated by the runtime; `--status` reports
|
|
26
|
+
* the mode it actually observes rather than asserting a POSIX guarantee the
|
|
27
|
+
* platform does not offer. */
|
|
28
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
31
|
+
/** Overrides the entitlement file location. Exists for tests and for anyone
|
|
32
|
+
* running with an unusual HOME; it is NOT a bypass — the file it points at
|
|
33
|
+
* still has to contain a validly signed token, so redirecting it cannot
|
|
34
|
+
* manufacture entitlement. */
|
|
35
|
+
export const SUPPORTER_FILE_ENV = "REPOSKEIN_SUPPORTER_FILE";
|
|
36
|
+
const DIR_NAME = "reposkein";
|
|
37
|
+
const FILE_NAME = "supporter.jwt";
|
|
38
|
+
/** Resolves the entitlement file path, in order:
|
|
39
|
+
*
|
|
40
|
+
* 1. `REPOSKEIN_SUPPORTER_FILE`, if absolute.
|
|
41
|
+
* 2. `$XDG_CONFIG_HOME/reposkein/supporter.jwt`, if absolute.
|
|
42
|
+
* 3. `~/.config/reposkein/supporter.jwt` — the documented location. */
|
|
43
|
+
export function supporterTokenPath(env = process.env) {
|
|
44
|
+
const override = (env[SUPPORTER_FILE_ENV] ?? "").trim();
|
|
45
|
+
if (override && isAbsolute(override))
|
|
46
|
+
return override;
|
|
47
|
+
const xdg = (env.XDG_CONFIG_HOME ?? "").trim();
|
|
48
|
+
const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".config");
|
|
49
|
+
return join(base, DIR_NAME, FILE_NAME);
|
|
50
|
+
}
|
|
51
|
+
/** Cheap existence + identity probe. Returns null for "no file" and for any
|
|
52
|
+
* error reading it — an unreadable entitlement file is indistinguishable
|
|
53
|
+
* from an absent one as far as entitlement goes, and this runs on a tool
|
|
54
|
+
* call's hot path, so it must never throw. */
|
|
55
|
+
export function statSupporterTokenFile(env = process.env) {
|
|
56
|
+
try {
|
|
57
|
+
const st = statSync(supporterTokenPath(env));
|
|
58
|
+
if (!st.isFile())
|
|
59
|
+
return null;
|
|
60
|
+
return { mtimeMs: st.mtimeMs, size: st.size, mode: st.mode & 0o777 };
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Reads the token text, or null if there is nothing readable there. Never
|
|
67
|
+
* throws. Size is bounded by the caller's own token-length limit; a file
|
|
68
|
+
* larger than that fails verification rather than being read into memory
|
|
69
|
+
* blindly, so the read is capped here too. */
|
|
70
|
+
export function readSupporterTokenFile(env = process.env, maxBytes = 64 * 1024) {
|
|
71
|
+
try {
|
|
72
|
+
const path = supporterTokenPath(env);
|
|
73
|
+
const st = statSync(path);
|
|
74
|
+
if (!st.isFile() || st.size > maxBytes)
|
|
75
|
+
return null;
|
|
76
|
+
const text = readFileSync(path, "utf8").trim();
|
|
77
|
+
return text.length === 0 ? null : text;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Writes the token, creating `~/.config/reposkein/` as 0700 if needed, and
|
|
84
|
+
* returns the path written. Throws on failure — this one is called from the
|
|
85
|
+
* CLI, where a failure must be reported, not swallowed. */
|
|
86
|
+
export function writeSupporterTokenFile(token, env = process.env) {
|
|
87
|
+
const path = supporterTokenPath(env);
|
|
88
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
89
|
+
writeFileSync(path, `${token.trim()}\n`, { mode: 0o600 });
|
|
90
|
+
// Re-assert: `mode` above applies only on creation.
|
|
91
|
+
try {
|
|
92
|
+
chmodSync(path, 0o600);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Filesystems without POSIX modes (some Windows/network mounts). The
|
|
96
|
+
// write itself succeeded, which is what the caller asked for.
|
|
97
|
+
}
|
|
98
|
+
return path;
|
|
99
|
+
}
|
|
100
|
+
/** Deletes the entitlement file. Returns true if a file was removed, false if
|
|
101
|
+
* there was nothing to remove. */
|
|
102
|
+
export function removeSupporterTokenFile(env = process.env) {
|
|
103
|
+
const path = supporterTokenPath(env);
|
|
104
|
+
try {
|
|
105
|
+
statSync(path);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
rmSync(path, { force: true });
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=supporterStore.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"supporterStore.js","sourceRoot":"","sources":["../../src/ads/supporterStore.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/** The supporter-entitlement token: format, minting, and verification.
|
|
2
|
+
*
|
|
3
|
+
* ## Why not a JWT
|
|
4
|
+
*
|
|
5
|
+
* A JWT's header is attacker-controlled — the `alg` field in particular has
|
|
6
|
+
* a long history of `none`/`HS256`-confusion bugs, and every JWT library
|
|
7
|
+
* carries the machinery to be configured wrong. This token has no header at
|
|
8
|
+
* all. The algorithm is Ed25519 because it is fixed in this file, the key is
|
|
9
|
+
* whichever committed public key the payload's `kid` names, and there is no
|
|
10
|
+
* wire field that can change either. That removes the entire class of
|
|
11
|
+
* algorithm-confusion attacks and removes a dependency at the same time:
|
|
12
|
+
* `node:crypto` speaks Ed25519 natively, so nothing new is installed.
|
|
13
|
+
*
|
|
14
|
+
* ## Wire format
|
|
15
|
+
*
|
|
16
|
+
* rsk1.<base64url(payload JSON)>.<base64url(signature)>
|
|
17
|
+
*
|
|
18
|
+
* The signature covers the ASCII bytes of `rsk1.<payloadSegment>` — the
|
|
19
|
+
* ENCODED segment, not the decoded JSON. Signing the encoding sidesteps
|
|
20
|
+
* every JSON-canonicalization question (key order, whitespace, number
|
|
21
|
+
* formatting): there is exactly one byte string that was signed, and it is
|
|
22
|
+
* the one sitting in the token. The `rsk1.` prefix is inside the signed
|
|
23
|
+
* input too, so a future `rsk2` format cannot be produced by re-labelling an
|
|
24
|
+
* `rsk1` token.
|
|
25
|
+
*
|
|
26
|
+
* ## Payload
|
|
27
|
+
*
|
|
28
|
+
* { v: 1, kid: "skein-2026-08", sub: "<opaque>", tier: "skein",
|
|
29
|
+
* iat: <unix seconds>, exp: <unix seconds> }
|
|
30
|
+
*
|
|
31
|
+
* `sub` is an opaque hash minted by the fulfilment worker (an HMAC of the
|
|
32
|
+
* supporter's Ko-fi email under a server-side salt), present only so a
|
|
33
|
+
* re-issued token can be recognised as the same subscription. It is not
|
|
34
|
+
* reversible to an email, it is never transmitted anywhere by this package,
|
|
35
|
+
* and nothing in RepoSkein reads it except `support --status`, which prints
|
|
36
|
+
* a prefix of it so a supporter can tell two tokens apart. There is no
|
|
37
|
+
* account, no device id, and no counter.
|
|
38
|
+
*
|
|
39
|
+
* ## What verification deliberately does NOT do
|
|
40
|
+
*
|
|
41
|
+
* No revocation list, and therefore no network call. A revocation check is a
|
|
42
|
+
* phone-home by another name, and the thing being protected is the absence
|
|
43
|
+
* of an ad — the least valuable secret in the system. A leaked token buys a
|
|
44
|
+
* stranger an ad-free experience they could equally have by setting
|
|
45
|
+
* `REPOSKEIN_ADS=off`. Expiry plus re-issue is the entire enforcement model,
|
|
46
|
+
* and that is proportionate. */
|
|
47
|
+
/** Format marker and the first component of the signed input. */
|
|
48
|
+
export declare const SUPPORTER_TOKEN_PREFIX = "rsk1";
|
|
49
|
+
/** The only tier that entitles anyone to anything today. Compared exactly. */
|
|
50
|
+
export declare const SUPPORTER_TIER = "skein";
|
|
51
|
+
/** How long a token keeps working after `exp`.
|
|
52
|
+
*
|
|
53
|
+
* Three days, and the reason is renewal timing, not generosity: Ko-fi bills
|
|
54
|
+
* a membership on its own schedule and the fulfilment worker mints the
|
|
55
|
+
* replacement only when that payment lands. Without a grace window a
|
|
56
|
+
* supporter whose renewal posts a few hours late would watch a paid-for
|
|
57
|
+
* feature switch itself off. Three days covers a weekend plus a payment
|
|
58
|
+
* retry; it is short enough that a cancelled membership stops mattering
|
|
59
|
+
* within the week. */
|
|
60
|
+
export declare const SUPPORTER_GRACE_MS: number;
|
|
61
|
+
/** Tolerance for a clock that is behind the minter's. A token whose `iat` is
|
|
62
|
+
* further in the future than this is rejected — a legitimate minter cannot
|
|
63
|
+
* produce one, so it means either a badly wrong clock or a forged payload,
|
|
64
|
+
* and both should fail loudly rather than quietly grant entitlement later. */
|
|
65
|
+
export declare const SUPPORTER_MAX_CLOCK_SKEW_MS: number;
|
|
66
|
+
/** Longest lifetime any single token may claim (400 days). Caps the blast
|
|
67
|
+
* radius if the signing key is ever compromised: an attacker with the key
|
|
68
|
+
* still cannot mint a token that outlives one rotation cycle. Renewal, not
|
|
69
|
+
* longevity, is the model. */
|
|
70
|
+
export declare const SUPPORTER_MAX_LIFETIME_MS: number;
|
|
71
|
+
/** Refuse to even parse anything larger. A supporter token is ~200 bytes; a
|
|
72
|
+
* megabyte of base64 in the entitlement file is not a token. */
|
|
73
|
+
export declare const SUPPORTER_MAX_TOKEN_BYTES = 4096;
|
|
74
|
+
export interface SupporterClaims {
|
|
75
|
+
v: 1;
|
|
76
|
+
kid: string;
|
|
77
|
+
sub: string;
|
|
78
|
+
tier: string;
|
|
79
|
+
/** Issued-at, unix SECONDS (not milliseconds). */
|
|
80
|
+
iat: number;
|
|
81
|
+
/** Expiry, unix SECONDS. */
|
|
82
|
+
exp: number;
|
|
83
|
+
}
|
|
84
|
+
/** Why a token was refused. Diagnostic only — shown by `support --status`,
|
|
85
|
+
* never transmitted. */
|
|
86
|
+
export type SupporterRejection = "empty" | "too_large" | "malformed" | "unknown_key" | "bad_signature" | "bad_payload" | "wrong_tier" | "implausible_lifetime" | "not_yet_valid";
|
|
87
|
+
export type SupporterVerdict =
|
|
88
|
+
/** Signature good, tier right, `exp` in the future. */
|
|
89
|
+
{
|
|
90
|
+
state: "valid";
|
|
91
|
+
claims: SupporterClaims;
|
|
92
|
+
}
|
|
93
|
+
/** Signature good, past `exp`, still inside the grace window. */
|
|
94
|
+
| {
|
|
95
|
+
state: "grace";
|
|
96
|
+
claims: SupporterClaims;
|
|
97
|
+
}
|
|
98
|
+
/** Signature good, past `exp` + grace. Claims are returned so the CLI can
|
|
99
|
+
* say *when* it lapsed rather than just "no". */
|
|
100
|
+
| {
|
|
101
|
+
state: "expired";
|
|
102
|
+
claims: SupporterClaims;
|
|
103
|
+
}
|
|
104
|
+
/** Never trustworthy at any time. */
|
|
105
|
+
| {
|
|
106
|
+
state: "invalid";
|
|
107
|
+
reason: SupporterRejection;
|
|
108
|
+
};
|
|
109
|
+
/** True when the verdict entitles the holder. The one place the
|
|
110
|
+
* valid/grace distinction collapses. */
|
|
111
|
+
export declare function isEntitled(verdict: SupporterVerdict): boolean;
|
|
112
|
+
/** Mints a token. Needs the PRIVATE key, which lives only in the fulfilment
|
|
113
|
+
* worker's secret store (see `supporterKey.ts` provenance) — nothing in the
|
|
114
|
+
* published package or the test suite uses the real one. Exported because a
|
|
115
|
+
* format is only trustworthy if the thing that writes it and the thing that
|
|
116
|
+
* reads it are specified together, and because the round-trip tests need to
|
|
117
|
+
* sign with a throwaway key. */
|
|
118
|
+
export declare function signSupporterToken(claims: SupporterClaims, privateKeyPem: string): string;
|
|
119
|
+
/** Full verification: shape, signature, tier, then expiry against `now`.
|
|
120
|
+
* Pure and synchronous — no filesystem, no clock injection beyond `now`, and
|
|
121
|
+
* nothing that can touch a socket. */
|
|
122
|
+
export declare function verifySupporterToken(token: string, now?: number): SupporterVerdict;
|
|
123
|
+
/** The time-dependent half, over claims whose signature is already trusted. */
|
|
124
|
+
export declare function classifyClaims(claims: SupporterClaims, now?: number): SupporterVerdict;
|
|
125
|
+
/** Verifies shape + signature only, deferring every time-dependent decision
|
|
126
|
+
* to `classifyClaims`. `isSupporter()`'s cache is built on this split. */
|
|
127
|
+
export declare function verifySupporterTokenClaims(token: string): {
|
|
128
|
+
ok: true;
|
|
129
|
+
claims: SupporterClaims;
|
|
130
|
+
} | {
|
|
131
|
+
ok: false;
|
|
132
|
+
reason: SupporterRejection;
|
|
133
|
+
};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/** The supporter-entitlement token: format, minting, and verification.
|
|
2
|
+
*
|
|
3
|
+
* ## Why not a JWT
|
|
4
|
+
*
|
|
5
|
+
* A JWT's header is attacker-controlled — the `alg` field in particular has
|
|
6
|
+
* a long history of `none`/`HS256`-confusion bugs, and every JWT library
|
|
7
|
+
* carries the machinery to be configured wrong. This token has no header at
|
|
8
|
+
* all. The algorithm is Ed25519 because it is fixed in this file, the key is
|
|
9
|
+
* whichever committed public key the payload's `kid` names, and there is no
|
|
10
|
+
* wire field that can change either. That removes the entire class of
|
|
11
|
+
* algorithm-confusion attacks and removes a dependency at the same time:
|
|
12
|
+
* `node:crypto` speaks Ed25519 natively, so nothing new is installed.
|
|
13
|
+
*
|
|
14
|
+
* ## Wire format
|
|
15
|
+
*
|
|
16
|
+
* rsk1.<base64url(payload JSON)>.<base64url(signature)>
|
|
17
|
+
*
|
|
18
|
+
* The signature covers the ASCII bytes of `rsk1.<payloadSegment>` — the
|
|
19
|
+
* ENCODED segment, not the decoded JSON. Signing the encoding sidesteps
|
|
20
|
+
* every JSON-canonicalization question (key order, whitespace, number
|
|
21
|
+
* formatting): there is exactly one byte string that was signed, and it is
|
|
22
|
+
* the one sitting in the token. The `rsk1.` prefix is inside the signed
|
|
23
|
+
* input too, so a future `rsk2` format cannot be produced by re-labelling an
|
|
24
|
+
* `rsk1` token.
|
|
25
|
+
*
|
|
26
|
+
* ## Payload
|
|
27
|
+
*
|
|
28
|
+
* { v: 1, kid: "skein-2026-08", sub: "<opaque>", tier: "skein",
|
|
29
|
+
* iat: <unix seconds>, exp: <unix seconds> }
|
|
30
|
+
*
|
|
31
|
+
* `sub` is an opaque hash minted by the fulfilment worker (an HMAC of the
|
|
32
|
+
* supporter's Ko-fi email under a server-side salt), present only so a
|
|
33
|
+
* re-issued token can be recognised as the same subscription. It is not
|
|
34
|
+
* reversible to an email, it is never transmitted anywhere by this package,
|
|
35
|
+
* and nothing in RepoSkein reads it except `support --status`, which prints
|
|
36
|
+
* a prefix of it so a supporter can tell two tokens apart. There is no
|
|
37
|
+
* account, no device id, and no counter.
|
|
38
|
+
*
|
|
39
|
+
* ## What verification deliberately does NOT do
|
|
40
|
+
*
|
|
41
|
+
* No revocation list, and therefore no network call. A revocation check is a
|
|
42
|
+
* phone-home by another name, and the thing being protected is the absence
|
|
43
|
+
* of an ad — the least valuable secret in the system. A leaked token buys a
|
|
44
|
+
* stranger an ad-free experience they could equally have by setting
|
|
45
|
+
* `REPOSKEIN_ADS=off`. Expiry plus re-issue is the entire enforcement model,
|
|
46
|
+
* and that is proportionate. */
|
|
47
|
+
import { createPublicKey, createPrivateKey, sign as cryptoSign, verify as cryptoVerify } from "node:crypto";
|
|
48
|
+
import { SUPPORTER_PUBLIC_KEYS } from "./supporterKey.js";
|
|
49
|
+
/** Format marker and the first component of the signed input. */
|
|
50
|
+
export const SUPPORTER_TOKEN_PREFIX = "rsk1";
|
|
51
|
+
/** The only tier that entitles anyone to anything today. Compared exactly. */
|
|
52
|
+
export const SUPPORTER_TIER = "skein";
|
|
53
|
+
/** How long a token keeps working after `exp`.
|
|
54
|
+
*
|
|
55
|
+
* Three days, and the reason is renewal timing, not generosity: Ko-fi bills
|
|
56
|
+
* a membership on its own schedule and the fulfilment worker mints the
|
|
57
|
+
* replacement only when that payment lands. Without a grace window a
|
|
58
|
+
* supporter whose renewal posts a few hours late would watch a paid-for
|
|
59
|
+
* feature switch itself off. Three days covers a weekend plus a payment
|
|
60
|
+
* retry; it is short enough that a cancelled membership stops mattering
|
|
61
|
+
* within the week. */
|
|
62
|
+
export const SUPPORTER_GRACE_MS = 3 * 24 * 60 * 60 * 1000;
|
|
63
|
+
/** Tolerance for a clock that is behind the minter's. A token whose `iat` is
|
|
64
|
+
* further in the future than this is rejected — a legitimate minter cannot
|
|
65
|
+
* produce one, so it means either a badly wrong clock or a forged payload,
|
|
66
|
+
* and both should fail loudly rather than quietly grant entitlement later. */
|
|
67
|
+
export const SUPPORTER_MAX_CLOCK_SKEW_MS = 24 * 60 * 60 * 1000;
|
|
68
|
+
/** Longest lifetime any single token may claim (400 days). Caps the blast
|
|
69
|
+
* radius if the signing key is ever compromised: an attacker with the key
|
|
70
|
+
* still cannot mint a token that outlives one rotation cycle. Renewal, not
|
|
71
|
+
* longevity, is the model. */
|
|
72
|
+
export const SUPPORTER_MAX_LIFETIME_MS = 400 * 24 * 60 * 60 * 1000;
|
|
73
|
+
/** Refuse to even parse anything larger. A supporter token is ~200 bytes; a
|
|
74
|
+
* megabyte of base64 in the entitlement file is not a token. */
|
|
75
|
+
export const SUPPORTER_MAX_TOKEN_BYTES = 4096;
|
|
76
|
+
/** True when the verdict entitles the holder. The one place the
|
|
77
|
+
* valid/grace distinction collapses. */
|
|
78
|
+
export function isEntitled(verdict) {
|
|
79
|
+
return verdict.state === "valid" || verdict.state === "grace";
|
|
80
|
+
}
|
|
81
|
+
const SEGMENT_RE = /^[A-Za-z0-9_-]+$/;
|
|
82
|
+
const KID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
83
|
+
const SUB_RE = /^[A-Za-z0-9_-]{8,64}$/;
|
|
84
|
+
function b64uEncode(buf) {
|
|
85
|
+
return buf.toString("base64url");
|
|
86
|
+
}
|
|
87
|
+
/** Decodes a segment only if it is canonical base64url: the alphabet, no
|
|
88
|
+
* padding, no whitespace. `Buffer.from(..., "base64url")` is famously
|
|
89
|
+
* lenient (it will happily skip garbage), so the charset gate happens first
|
|
90
|
+
* and a token with a sloppy encoding is simply not a token. */
|
|
91
|
+
function b64uDecode(seg) {
|
|
92
|
+
if (!SEGMENT_RE.test(seg))
|
|
93
|
+
return null;
|
|
94
|
+
const buf = Buffer.from(seg, "base64url");
|
|
95
|
+
if (buf.length === 0)
|
|
96
|
+
return null;
|
|
97
|
+
// Round-trip guard: re-encoding must reproduce the segment exactly, which
|
|
98
|
+
// rules out the trailing-bit slack base64 otherwise tolerates.
|
|
99
|
+
if (buf.toString("base64url") !== seg)
|
|
100
|
+
return null;
|
|
101
|
+
return buf;
|
|
102
|
+
}
|
|
103
|
+
/** Mints a token. Needs the PRIVATE key, which lives only in the fulfilment
|
|
104
|
+
* worker's secret store (see `supporterKey.ts` provenance) — nothing in the
|
|
105
|
+
* published package or the test suite uses the real one. Exported because a
|
|
106
|
+
* format is only trustworthy if the thing that writes it and the thing that
|
|
107
|
+
* reads it are specified together, and because the round-trip tests need to
|
|
108
|
+
* sign with a throwaway key. */
|
|
109
|
+
export function signSupporterToken(claims, privateKeyPem) {
|
|
110
|
+
const key = createPrivateKey(privateKeyPem);
|
|
111
|
+
if (key.asymmetricKeyType !== "ed25519") {
|
|
112
|
+
throw new Error(`supporter token: signing key must be ed25519, got ${key.asymmetricKeyType ?? "unknown"}`);
|
|
113
|
+
}
|
|
114
|
+
const payloadSeg = b64uEncode(Buffer.from(JSON.stringify(claims), "utf8"));
|
|
115
|
+
const signingInput = `${SUPPORTER_TOKEN_PREFIX}.${payloadSeg}`;
|
|
116
|
+
// `null` algorithm: Ed25519 hashes internally and node:crypto requires the
|
|
117
|
+
// digest argument to be null for it.
|
|
118
|
+
const sig = cryptoSign(null, Buffer.from(signingInput, "ascii"), key);
|
|
119
|
+
return `${signingInput}.${b64uEncode(sig)}`;
|
|
120
|
+
}
|
|
121
|
+
/** Structural + cryptographic checks, with no notion of "now". Split out so
|
|
122
|
+
* the time-dependent part is a pure function of already-verified claims —
|
|
123
|
+
* which is what lets `isSupporter()` cache the expensive half and re-decide
|
|
124
|
+
* expiry on every call for free. */
|
|
125
|
+
function verifyShapeAndSignature(token) {
|
|
126
|
+
const trimmed = token.trim();
|
|
127
|
+
if (trimmed.length === 0)
|
|
128
|
+
return { ok: false, reason: "empty" };
|
|
129
|
+
if (Buffer.byteLength(trimmed, "utf8") > SUPPORTER_MAX_TOKEN_BYTES)
|
|
130
|
+
return { ok: false, reason: "too_large" };
|
|
131
|
+
const parts = trimmed.split(".");
|
|
132
|
+
if (parts.length !== 3)
|
|
133
|
+
return { ok: false, reason: "malformed" };
|
|
134
|
+
const [prefix, payloadSeg, sigSeg] = parts;
|
|
135
|
+
if (prefix !== SUPPORTER_TOKEN_PREFIX)
|
|
136
|
+
return { ok: false, reason: "malformed" };
|
|
137
|
+
const sigBytes = b64uDecode(sigSeg);
|
|
138
|
+
// Ed25519 signatures are exactly 64 bytes; anything else is not one.
|
|
139
|
+
if (!sigBytes || sigBytes.length !== 64)
|
|
140
|
+
return { ok: false, reason: "malformed" };
|
|
141
|
+
const payloadBytes = b64uDecode(payloadSeg);
|
|
142
|
+
if (!payloadBytes)
|
|
143
|
+
return { ok: false, reason: "malformed" };
|
|
144
|
+
let parsed;
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(payloadBytes.toString("utf8"));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return { ok: false, reason: "bad_payload" };
|
|
150
|
+
}
|
|
151
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
152
|
+
return { ok: false, reason: "bad_payload" };
|
|
153
|
+
const p = parsed;
|
|
154
|
+
if (p.v !== 1)
|
|
155
|
+
return { ok: false, reason: "bad_payload" };
|
|
156
|
+
const kid = p.kid;
|
|
157
|
+
if (typeof kid !== "string" || !KID_RE.test(kid))
|
|
158
|
+
return { ok: false, reason: "bad_payload" };
|
|
159
|
+
const sub = p.sub;
|
|
160
|
+
if (typeof sub !== "string" || !SUB_RE.test(sub))
|
|
161
|
+
return { ok: false, reason: "bad_payload" };
|
|
162
|
+
const tier = p.tier;
|
|
163
|
+
if (typeof tier !== "string")
|
|
164
|
+
return { ok: false, reason: "bad_payload" };
|
|
165
|
+
const iat = p.iat;
|
|
166
|
+
const exp = p.exp;
|
|
167
|
+
if (!Number.isSafeInteger(iat) || !Number.isSafeInteger(exp))
|
|
168
|
+
return { ok: false, reason: "bad_payload" };
|
|
169
|
+
const iatN = iat;
|
|
170
|
+
const expN = exp;
|
|
171
|
+
if (iatN <= 0 || expN <= iatN)
|
|
172
|
+
return { ok: false, reason: "bad_payload" };
|
|
173
|
+
// Key selection happens BEFORE the signature check and is driven entirely
|
|
174
|
+
// by the committed key map: an unknown `kid` can never reach verification,
|
|
175
|
+
// so the token cannot nominate its own trust root.
|
|
176
|
+
const pem = Object.prototype.hasOwnProperty.call(SUPPORTER_PUBLIC_KEYS, kid)
|
|
177
|
+
? SUPPORTER_PUBLIC_KEYS[kid]
|
|
178
|
+
: undefined;
|
|
179
|
+
if (!pem)
|
|
180
|
+
return { ok: false, reason: "unknown_key" };
|
|
181
|
+
let signatureOk = false;
|
|
182
|
+
try {
|
|
183
|
+
const publicKey = createPublicKey(pem);
|
|
184
|
+
// Belt and braces against a future edit to the committed key map putting
|
|
185
|
+
// a non-Ed25519 key in it. `crypto.verify(null, …)` would throw for most
|
|
186
|
+
// other key types and be caught below, but an explicit refusal is
|
|
187
|
+
// clearer than relying on which algorithms happen to reject a null
|
|
188
|
+
// digest — and it is the check that keeps "the algorithm is Ed25519
|
|
189
|
+
// because this file says so" literally true.
|
|
190
|
+
if (publicKey.asymmetricKeyType !== "ed25519")
|
|
191
|
+
return { ok: false, reason: "unknown_key" };
|
|
192
|
+
signatureOk = cryptoVerify(null, Buffer.from(`${SUPPORTER_TOKEN_PREFIX}.${payloadSeg}`, "ascii"), publicKey, sigBytes);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
signatureOk = false;
|
|
196
|
+
}
|
|
197
|
+
if (!signatureOk)
|
|
198
|
+
return { ok: false, reason: "bad_signature" };
|
|
199
|
+
// Tier and lifetime are checked only AFTER the signature: a rejection
|
|
200
|
+
// before that would be a statement about an unauthenticated payload.
|
|
201
|
+
if (tier !== SUPPORTER_TIER)
|
|
202
|
+
return { ok: false, reason: "wrong_tier" };
|
|
203
|
+
if ((expN - iatN) * 1000 > SUPPORTER_MAX_LIFETIME_MS)
|
|
204
|
+
return { ok: false, reason: "implausible_lifetime" };
|
|
205
|
+
return { ok: true, claims: { v: 1, kid, sub, tier, iat: iatN, exp: expN } };
|
|
206
|
+
}
|
|
207
|
+
/** Full verification: shape, signature, tier, then expiry against `now`.
|
|
208
|
+
* Pure and synchronous — no filesystem, no clock injection beyond `now`, and
|
|
209
|
+
* nothing that can touch a socket. */
|
|
210
|
+
export function verifySupporterToken(token, now = Date.now()) {
|
|
211
|
+
const base = verifyShapeAndSignature(token);
|
|
212
|
+
if (!base.ok)
|
|
213
|
+
return { state: "invalid", reason: base.reason };
|
|
214
|
+
return classifyClaims(base.claims, now);
|
|
215
|
+
}
|
|
216
|
+
/** The time-dependent half, over claims whose signature is already trusted. */
|
|
217
|
+
export function classifyClaims(claims, now = Date.now()) {
|
|
218
|
+
if (claims.iat * 1000 > now + SUPPORTER_MAX_CLOCK_SKEW_MS) {
|
|
219
|
+
return { state: "invalid", reason: "not_yet_valid" };
|
|
220
|
+
}
|
|
221
|
+
const expMs = claims.exp * 1000;
|
|
222
|
+
if (now <= expMs)
|
|
223
|
+
return { state: "valid", claims };
|
|
224
|
+
if (now <= expMs + SUPPORTER_GRACE_MS)
|
|
225
|
+
return { state: "grace", claims };
|
|
226
|
+
return { state: "expired", claims };
|
|
227
|
+
}
|
|
228
|
+
/** Verifies shape + signature only, deferring every time-dependent decision
|
|
229
|
+
* to `classifyClaims`. `isSupporter()`'s cache is built on this split. */
|
|
230
|
+
export function verifySupporterTokenClaims(token) {
|
|
231
|
+
return verifyShapeAndSignature(token);
|
|
232
|
+
}
|
|
233
|
+
//# sourceMappingURL=supporterToken.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"supporterToken.js","sourceRoot":"","sources":["../../src/ads/supporterToken.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** The ONLY shape sponsored content is ever allowed to take inside this
|
|
2
|
+
* process (see .reposkein/decisions/2026-08-21-sponsorship-placement-*.json:
|
|
3
|
+
* "Sponsor payloads are fixed-schema and length-capped, not freeform text or
|
|
4
|
+
* markup"). Anything a network returns is projected onto this and rejected if
|
|
5
|
+
* it doesn't fit — never merged, never spread, never passed through.
|
|
6
|
+
*
|
|
7
|
+
* `label` is a literal, constructed locally by `sanitizeSponsored` and never
|
|
8
|
+
* copied from the payload: the disclosure is a property of OUR envelope, not
|
|
9
|
+
* sponsor-supplied content, which is the same immutability rule the ADR fixes
|
|
10
|
+
* for the viewer chip. */
|
|
11
|
+
export interface SponsoredSlot {
|
|
12
|
+
readonly label: typeof SPONSORED_LABEL;
|
|
13
|
+
/** Headline, <= TITLE_MAX chars, plain text. */
|
|
14
|
+
readonly title?: string;
|
|
15
|
+
/** Body copy, <= BODY_MAX chars, plain text. */
|
|
16
|
+
readonly body?: string;
|
|
17
|
+
/** https, host in the ad network's click-domain allowlist. */
|
|
18
|
+
readonly url: string;
|
|
19
|
+
}
|
|
20
|
+
/** Immutable. Renaming this constant renames a disclosure, which is exactly
|
|
21
|
+
* what the governing ADR forbids — the "label is immutable" block in
|
|
22
|
+
* `mcp/test/adsSanitize.test.ts` asserts the literal survives every payload
|
|
23
|
+
* that tries to rename, blank, or drop it. */
|
|
24
|
+
export declare const SPONSORED_LABEL = "sponsored";
|
|
25
|
+
/** Response-envelope key the slot is attached under. `_meta` (not `content`,
|
|
26
|
+
* not a new `structuredContent`) so the slot can never displace, reorder, or
|
|
27
|
+
* concatenate itself into the tool's own answer. */
|
|
28
|
+
export declare const SPONSORED_META_KEY = "reposkein/sponsored";
|
|
29
|
+
export declare const TITLE_MAX = 80;
|
|
30
|
+
export declare const BODY_MAX = 200;
|
|
31
|
+
export declare const URL_MAX = 512;
|
|
32
|
+
/** Anything larger than this isn't a slot payload; reject before parsing fields. */
|
|
33
|
+
export declare const PAYLOAD_MAX_BYTES = 4096;
|
|
34
|
+
/** Everything that leaves this machine for a slot request.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately not extensible: `tool` is a tool NAME from this server's own
|
|
37
|
+
* registry (a fixed, public string — `get_context_profile`), and `keywords`
|
|
38
|
+
* is coarse free text the caller opted to share. No file paths, no node ids,
|
|
39
|
+
* no code, no summaries, no repo id, no identity. See docs/SPONSORSHIP.md. */
|
|
40
|
+
export interface SlotContext {
|
|
41
|
+
readonly tool: string;
|
|
42
|
+
readonly keywords?: string;
|
|
43
|
+
}
|
|
44
|
+
/** A source of raw (untrusted) slot payloads. One implementation talks to the
|
|
45
|
+
* ad network (`luluAdsSource`); tests inject fakes. The return value is
|
|
46
|
+
* `unknown` on purpose — a source is a transport, never a validator; the
|
|
47
|
+
* ONLY validator is `sanitizeSponsored`. */
|
|
48
|
+
export interface SponsoredSource {
|
|
49
|
+
requestSlot(ctx: SlotContext, opts: {
|
|
50
|
+
timeoutMs: number;
|
|
51
|
+
signal: AbortSignal;
|
|
52
|
+
}): Promise<unknown>;
|
|
53
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Immutable. Renaming this constant renames a disclosure, which is exactly
|
|
2
|
+
* what the governing ADR forbids — the "label is immutable" block in
|
|
3
|
+
* `mcp/test/adsSanitize.test.ts` asserts the literal survives every payload
|
|
4
|
+
* that tries to rename, blank, or drop it. */
|
|
5
|
+
export const SPONSORED_LABEL = "sponsored";
|
|
6
|
+
/** Response-envelope key the slot is attached under. `_meta` (not `content`,
|
|
7
|
+
* not a new `structuredContent`) so the slot can never displace, reorder, or
|
|
8
|
+
* concatenate itself into the tool's own answer. */
|
|
9
|
+
export const SPONSORED_META_KEY = "reposkein/sponsored";
|
|
10
|
+
export const TITLE_MAX = 80;
|
|
11
|
+
export const BODY_MAX = 200;
|
|
12
|
+
export const URL_MAX = 512;
|
|
13
|
+
/** Anything larger than this isn't a slot payload; reject before parsing fields. */
|
|
14
|
+
export const PAYLOAD_MAX_BYTES = 4096;
|
|
15
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/ads/types.ts"],"names":[],"mappings":"AAoBA;;;+CAG+C;AAC/C,MAAM,CAAC,MAAM,eAAe,GAAG,WAAW,CAAC;AAE3C;;qDAEqD;AACrD,MAAM,CAAC,MAAM,kBAAkB,GAAG,qBAAqB,CAAC;AAExD,MAAM,CAAC,MAAM,SAAS,GAAG,EAAE,CAAC;AAC5B,MAAM,CAAC,MAAM,QAAQ,GAAG,GAAG,CAAC;AAC5B,MAAM,CAAC,MAAM,OAAO,GAAG,GAAG,CAAC;AAC3B,oFAAoF;AACpF,MAAM,CAAC,MAAM,iBAAiB,GAAG,IAAI,CAAC"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** `reposkein-mcp support` — install, inspect, and remove a supporter
|
|
2
|
+
* entitlement token.
|
|
3
|
+
*
|
|
4
|
+
* Four shapes, no subcommands:
|
|
5
|
+
*
|
|
6
|
+
* reposkein-mcp support <token> verify and store it
|
|
7
|
+
* reposkein-mcp support - read the token from stdin instead
|
|
8
|
+
* reposkein-mcp support --status what is installed and until when
|
|
9
|
+
* reposkein-mcp support --remove delete it
|
|
10
|
+
*
|
|
11
|
+
* `-` exists because a token passed as an argument is not private: it lands
|
|
12
|
+
* in `~/.bash_history`, and while the process runs it is visible in `ps` and
|
|
13
|
+
* `/proc/<pid>/cmdline` to every other user on the machine. Piping it in
|
|
14
|
+
* keeps it off both. It is not a secret worth much — the worst outcome of a
|
|
15
|
+
* leak is somebody else getting no ads — but "it barely matters" is a poor
|
|
16
|
+
* reason to make the private option unavailable.
|
|
17
|
+
*
|
|
18
|
+
* The token is verified BEFORE it is written: an install that stores
|
|
19
|
+
* unverified bytes and only complains later leaves the user with a file that
|
|
20
|
+
* looks installed and does nothing. Nothing here contacts the network — see
|
|
21
|
+
* `ads/supporter.ts` — so `support` works offline, which matters because the
|
|
22
|
+
* token typically arrives by copy-paste into a terminal that may not be the
|
|
23
|
+
* one that fetched it. */
|
|
24
|
+
import { type SupporterStatus } from "../ads/supporter.js";
|
|
25
|
+
import { type SupporterRejection } from "../ads/supporterToken.js";
|
|
26
|
+
export interface SupportArgs {
|
|
27
|
+
mode: "install" | "status" | "remove";
|
|
28
|
+
token?: string;
|
|
29
|
+
/** `-` was given: the token comes from stdin, not from argv. */
|
|
30
|
+
stdin?: boolean;
|
|
31
|
+
json: boolean;
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function parseSupportArgs(argv: string[]): SupportArgs;
|
|
35
|
+
/** Human-readable explanation of a rejection. Deliberately specific: "invalid
|
|
36
|
+
* token" tells a paying supporter nothing about whether to re-copy it or ask
|
|
37
|
+
* for a new one. */
|
|
38
|
+
export declare function explainRejection(reason: SupporterRejection): string;
|
|
39
|
+
export declare function renderStatus(status: SupporterStatus, now: number, color: boolean): string;
|
|
40
|
+
export declare function renderStatusJson(status: SupporterStatus, now: number): string;
|
|
41
|
+
/** Entry point. Returns the process exit code: 0 when the machine ends up
|
|
42
|
+
* entitled (or a removal succeeded), 1 otherwise — so a script can branch on
|
|
43
|
+
* `reposkein-mcp support --status` without parsing text.
|
|
44
|
+
*
|
|
45
|
+
* `readStdin` is a test seam; production always reads fd 0. */
|
|
46
|
+
export declare function runSupport(argv: string[], env?: NodeJS.ProcessEnv, now?: number, readStdin?: () => string): number;
|