feat: add stealth: true frontmatter for permanently hidden encrypted pages

Stealth pages are encrypted pages that are hidden from every listing
surface permanently, even after a successful client-side decryption.
Their metadata is never written to the shadow content index, so there
is nothing for the client to recover -- the page remains invisible to
graph, explorer, search, backlinks, recent-notes, folder-page, and
tag-page for the entire browser session.

This fills a gap between "unlisted" (hidden pre-decryption, revealed
post-decryption via shadow index) and the desire for "secret door"
pages that should only be accessible to users who already know the
exact URL.

Semantics:

- `stealth: true` implies `unlisted: true`. Setting stealth on a page
  automatically hides it from listings regardless of the `unlisted`
  field or the `unlistWhenEncrypted` plugin option.
- `stealth: true` overrides explicit `unlisted: false`. A user who
  writes `stealth: true, unlisted: false` gets a hidden stealth page,
  because stealth without unlisted is semantically incoherent.
- `stealth: true` has no effect on non-encrypted pages. Stealth only
  controls shadow-index emission, which only happens for encrypted
  unlisted pages. Non-encrypted pages with `stealth: true` are silently
  ignored (use `unlisted: true` for non-encrypted hiding, via the
  `unlisted-pages` plugin).
- Non-boolean stealth values are ignored.

Implementation:

- transformer.ts: after setting `data.unlisted`, check if
  `frontmatter.stealth === true` and if so set `data.stealth = true`
  and force `data.unlisted = true`.
- emitter.ts: in the shadow-index loop, skip any page where
  `data.stealth === true`.

Tests: 8 new (6 transformer + 2 emitter) covering stealth -> unlisted
implication, stealth overriding explicit unlisted: false, stealth on
non-encrypted pages being a no-op, non-boolean stealth values being
ignored, stealth pages being excluded from the shadow index while
non-stealth pages pass through unchanged, and mixed stealth/non-stealth
content being correctly partitioned. 29 tests total passing.
This commit is contained in:
saberzero1 2026-04-11 14:52:11 +02:00
parent bcd9743521
commit 8328b55648
No known key found for this signature in database
8 changed files with 137 additions and 7 deletions

View file

@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `EncryptedContentIndex` emitter that writes a versioned shadow content index (`static/encryptedContentIndex.json`) containing opaque encrypted blobs of per-page metadata (slug, title, links, tags) for pages marked `unlisted: true`. The shadow index uses a flat array format so no slugs or titles leak to anonymous visitors.
- `unlistWhenEncrypted` option on the `EncryptedPages` transformer. When `true`, encrypted pages are marked `file.data.unlisted = true`, hiding them from every build-time listing surface that respects the `unlisted` convention (contentIndex, RSS, sitemap, backlinks, recent-notes, folder-page, tag-page, graph, explorer, search) while still emitting the HTML so the page remains accessible by direct URL.
- Per-page `unlisted: true | false` frontmatter override. Explicit `unlisted: false` forces the page listed even when `unlistWhenEncrypted` is set.
- Per-page `stealth: true` frontmatter field. Stealth pages are encrypted pages that are hidden from every listing surface **permanently**, even after a successful client-side decryption — their metadata is never written to the shadow content index, so there is nothing for the client to recover. Useful for "secret door" pages that should only be accessible to users who already know the exact URL. `stealth: true` implies `unlisted: true` and overrides any explicit `unlisted: false`. No effect on non-encrypted pages.
- Client-side shadow-index unlocking: on every page load with cached passwords, the client script fetches the shadow index, decrypts entries with cached passwords, patches the resolved `fetchData` object in place, and dispatches `content-index-updated` so graph, explorer, and search re-initialize with the unlocked pages.
- `encryptAesGcm`, `decrypt`, and `SHADOW_INDEX_VERSION` exports for test and extension use.
- `ShadowIndexBlob`, `ShadowIndexFile`, and `ShadowContentIndexEntry` type exports.

View file

@ -69,10 +69,11 @@ All options below are set at the single plugin-entry level and are shared betwee
### Frontmatter fields
| Field | Type | Description |
| ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `password` | `string` | Enables encryption for this page. The field name is configurable via `passwordField`. |
| `unlisted` | `boolean` | Overrides `unlistWhenEncrypted`. `true` forces the page unlisted; `false` forces it listed even when `unlistWhenEncrypted` is also set. |
| Field | Type | Description |
| ---------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `password` | `string` | Enables encryption for this page. The field name is configurable via `passwordField`. |
| `unlisted` | `boolean` | Overrides `unlistWhenEncrypted`. `true` forces the page unlisted; `false` forces it listed even when `unlistWhenEncrypted` is also set. |
| `stealth` | `boolean` | When `true` and the page is encrypted, suppresses the page's entry in the shadow content index. The page stays hidden from every listing surface **even after successful client-side decryption**. Implies `unlisted: true` (overrides any explicit `unlisted: false`). No effect on non-encrypted pages. |
## How `unlisted` works
@ -84,6 +85,27 @@ When an encrypted page is marked `unlisted: true`:
- On any page load, if the user has cached passwords in sessionStorage from a previous successful decryption, the client script fetches the shadow index, decrypts entries matching those passwords, and patches the in-memory content index in place. A `content-index-updated` event is dispatched so graph, explorer, and search reinitialize with the unlocked entries.
- Server-side rendered listings (backlinks, recent-notes, folder-page, tag-page) remain statically hidden even after client-side decryption. This is a deliberate trade-off: those surfaces are HTML baked at build time and cannot be patched client-side.
## How `stealth` works
When an encrypted page is marked `stealth: true`, it behaves like an unlisted page with one critical difference: its entry is **never** emitted to the shadow content index. As a result:
- The page HTML is still emitted at its normal URL and accessible to anyone who knows it.
- The page is absent from `contentIndex.json`, RSS, sitemap, graph, explorer, search, backlinks, recent notes, folder listings, and tag listings — same as any `unlisted` page.
- Unlike `unlisted`, **successful client-side decryption does not reveal the page in graph, explorer, or search**. There is no shadow-index entry to decrypt, so the in-memory content index is never patched for this page.
- The password cache still works: a user who unlocks a stealth page once will not have to re-enter the password on subsequent visits within the same session. Only discovery surfaces are affected.
Use `stealth: true` when you want a "secret door" page: accessible only to users who already know the exact URL, invisible to everyone else permanently, even to users who have successfully decrypted other encrypted pages on the same site.
```yaml
---
title: Deep Secret
password: hunter2
stealth: true
---
```
Setting `stealth: true` is equivalent to `unlisted: true` on a page that does not also have a `password` field — the flag has no effect on non-encrypted pages, because stealth only controls the shadow-index emission, and non-encrypted pages do not have shadow-index entries in the first place.
## Security
- AES-256-GCM with 16-byte salt, 12-byte IV, 16-byte auth tag.

5
dist/index.js vendored
View file

@ -2274,6 +2274,10 @@ var rehypeEncryptedPages = (options) => {
} else if (options.unlistWhenEncrypted) {
data.unlisted = true;
}
if (frontmatter.stealth === true) {
data.stealth = true;
data.unlisted = true;
}
};
};
var EncryptedPages = (userOptions) => {
@ -2320,6 +2324,7 @@ var EncryptedContentIndex = (userOptions) => {
const data = file.data ?? {};
if (data.encrypted !== true) continue;
if (data.unlisted !== true) continue;
if (data.stealth === true) continue;
const frontmatter = data.frontmatter ?? {};
const password = frontmatter[passwordField];
if (typeof password !== "string" || password.length === 0) continue;

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

View file

@ -84,6 +84,7 @@ export const EncryptedContentIndex: QuartzEmitterPlugin<Partial<EncryptedContent
const data = (file.data ?? {}) as Record<string, unknown>;
if (data.encrypted !== true) continue;
if (data.unlisted !== true) continue;
if (data.stealth === true) continue;
const frontmatter = (data.frontmatter as Record<string, unknown> | undefined) ?? {};
const password = frontmatter[passwordField];

View file

@ -88,6 +88,11 @@ const rehypeEncryptedPages = (options: EncryptedPagesOptions): Plugin<[], HastRo
} else if (options.unlistWhenEncrypted) {
data.unlisted = true;
}
if (frontmatter.stealth === true) {
data.stealth = true;
data.unlisted = true;
}
};
};

View file

@ -15,6 +15,7 @@ function makeEncryptedContent(
password: string,
opts: {
unlisted?: boolean;
stealth?: boolean;
title?: string;
tags?: string[];
links?: string[];
@ -38,7 +39,7 @@ function makeEncryptedContent(
};
const vfile = new VFile("");
vfile.data = {
const data: Record<string, unknown> = {
slug,
relativePath: `${slug}.md`,
encrypted: true,
@ -51,7 +52,9 @@ function makeEncryptedContent(
password,
tags: opts.tags ?? [],
},
} as Record<string, unknown>;
};
if (opts.stealth) data.stealth = true;
vfile.data = data;
return [tree, vfile];
}
@ -188,4 +191,29 @@ describe("EncryptedContentIndex emitter", () => {
expect(shadow.version).toBe(SHADOW_INDEX_VERSION);
expect(shadow.entries).toEqual([]);
});
it("skips stealth pages from the shadow index", async () => {
const content = [makeEncryptedContent("secret/stealth", "pw1", { stealth: true })];
const shadow = await runEmitter(content);
expect(shadow.entries).toHaveLength(0);
});
it("includes non-stealth pages alongside stealth pages (only stealth is skipped)", async () => {
const content = [
makeEncryptedContent("secret/revealable", "pw1"),
makeEncryptedContent("secret/stealth", "pw1", { stealth: true }),
makeEncryptedContent("secret/also-revealable", "pw1"),
];
const shadow = await runEmitter(content);
expect(shadow.entries).toHaveLength(2);
const decoded = shadow.entries.map(
(e) => JSON.parse(decrypt(e.ciphertext, "pw1", e.iterations)) as ShadowContentIndexEntry,
);
const slugs = new Set(decoded.map((d) => d.slug));
expect(slugs).toEqual(new Set(["secret/revealable", "secret/also-revealable"]));
expect(slugs.has("secret/stealth")).toBe(false);
});
});

View file

@ -209,4 +209,72 @@ describe("EncryptedPages transformer", () => {
expect((vfile.data as Record<string, unknown>).encrypted).toBe(true);
expect((vfile.data as Record<string, unknown>).unlisted).toBe(false);
});
it("marks encrypted pages as stealth when frontmatter.stealth is true", async () => {
const tree = createHastTree("Stealth content");
const vfile = new VFile("");
vfile.data = { frontmatter: { title: "Test", password: "pw", stealth: true } };
await runTransformer(tree, vfile);
expect((vfile.data as Record<string, unknown>).encrypted).toBe(true);
expect((vfile.data as Record<string, unknown>).stealth).toBe(true);
});
it("stealth implies unlisted, even when frontmatter.unlisted is absent", async () => {
const tree = createHastTree("Stealth content");
const vfile = new VFile("");
vfile.data = { frontmatter: { title: "Test", password: "pw", stealth: true } };
await runTransformer(tree, vfile);
expect((vfile.data as Record<string, unknown>).unlisted).toBe(true);
});
it("stealth overrides explicit frontmatter.unlisted: false", async () => {
const tree = createHastTree("Stealth content");
const vfile = new VFile("");
vfile.data = {
frontmatter: { title: "Test", password: "pw", stealth: true, unlisted: false },
};
await runTransformer(tree, vfile);
expect((vfile.data as Record<string, unknown>).stealth).toBe(true);
expect((vfile.data as Record<string, unknown>).unlisted).toBe(true);
});
it("does not mark non-encrypted pages as stealth even when frontmatter.stealth is true", async () => {
const tree = createHastTree("Public content");
const vfile = new VFile("");
vfile.data = { frontmatter: { title: "Public Page", stealth: true } };
await runTransformer(tree, vfile);
expect((vfile.data as Record<string, unknown>).encrypted).toBeUndefined();
expect((vfile.data as Record<string, unknown>).stealth).toBeUndefined();
expect((vfile.data as Record<string, unknown>).unlisted).toBeUndefined();
});
it("ignores non-boolean stealth values (string)", async () => {
const tree = createHastTree("Secret");
const vfile = new VFile("");
vfile.data = { frontmatter: { title: "Test", password: "pw", stealth: "true" } };
await runTransformer(tree, vfile);
expect((vfile.data as Record<string, unknown>).encrypted).toBe(true);
expect((vfile.data as Record<string, unknown>).stealth).toBeUndefined();
});
it("does not mark encrypted pages as stealth when stealth is false", async () => {
const tree = createHastTree("Secret");
const vfile = new VFile("");
vfile.data = { frontmatter: { title: "Test", password: "pw", stealth: false, unlisted: true } };
await runTransformer(tree, vfile);
expect((vfile.data as Record<string, unknown>).stealth).toBeUndefined();
expect((vfile.data as Record<string, unknown>).unlisted).toBe(true);
});
});