Compare commits

...
4 Commits
Author SHA1 Message Date
Kristian WindandClaude Opus 5 7e28d8bb92 chore: untrack a vite temp file, and stop it coming back
Release / Build and publish image (push) Failing after 9m56s
vite.config.js.timestamp-*.mjs is written while bundling the config and
removed again — unless the process is killed, which is exactly what
happens during a hung build. One had been committed by accident; five
more appeared while working out why the build was hanging.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 14:04:03 +02:00
Kristian WindandClaude Opus 5 e63d852847 feat(mcp): let the connector address carry its own key
verdande could not be added to Claude at all. The documented setup —
POST /api/v1/mcp with an Authorization header — cannot be configured from
Claude's custom-connector dialog, which asks for a name, a URL and OAuth
client credentials and has no field for a bearer token. The instructions
described something the interface does not offer.

Worse, the obvious guess failed silently in the most misleading way
available: /mcp was not a route, so the SPA fallback answered 200 with a
page of HTML. The connector reported a successful connection and then
failed to parse the response, which reads as a broken client rather than
a missing endpoint.

So /mcp exists now and takes its credential in the query string, exactly
as the calendar feed does and for the same reason: a client that cannot
be told to send a header means the address is the credential. The
request logger records paths without their query strings, so the key does
not reach the log.

It is deliberately outside /api/v1 and accepts the key and nothing else.
Reading a session cookie here would make it a state-changing POST outside
the CSRF check — a cross-site request that acts as whoever is signed in —
and there is a test that fails if that ever becomes true.

The API path is unchanged for anything that can send a header. The tokens
page now shows the connector address next to the token it belongs to,
since that is the one moment the token exists.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 14:04:03 +02:00
Kristian WindandClaude Opus 5 d7e250f860 docs(rune): a rune does not update itself, and the server need not be deleted
Two corrections after checking against Yggdrasil's own behaviour rather
than guessing at it.

The advice to delete the failed server and make another was wrong. The
image reference is read from the rune at container-create time, and both
Start and Restart recreate the container — so once the image can be
fetched, pressing Start on the server you already have is enough.

And a rune does not pick up a changed manifest on its own. The panel
writes the rune row from a UI action only; neither a restart nor a timer
does it. So a new manifest needs Runes → verdande → Update, and the
catalogue list is cached in memory for ten minutes, which is long enough
that two attempts in a row can both hand back the old version.

The host-pull fallback now says what it costs: the panel re-pulls on
every recreate, fails quietly, and carries on with the local image, so a
new release looks like it rolled out when it did not. It is a stopgap for
keeping the package private, not a way to run this.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 12:27:02 +02:00
Kristian WindandClaude Opus 5 a8a29a963d docs: the registry has to be public, and docker login was the wrong advice
Installing the Rune still failed after v0.2.0 was published, with the same
message as before:

    create container: Error response from daemon:
    No such image: ghcr.io/kristianwind/verdande:latest

The advice in these pages — log the host's Docker daemon in — cannot
work, and Yggdrasil's source says why. Its PullImage calls the Docker
SDK's ImagePull with an empty image.PullOptions{}; RegistryAuth is never
set, and there are no registry credentials anywhere in the panel. A
private image answers 401. pullImageRetry discards that error on purpose
("second failure surfaces at create"), and ContainerCreate — which only
ever looks locally — then reports the image as missing. The message is a
liar: the tag is in the registry the whole time.

docker login does not change this. It is client-side: the daemon stores
no credentials, the CLI sends them itself with each request, and
Yggdrasil is a different client that sends none.

So the package has to be public, and the pages now say that rather than
offering a login that does nothing. Keeping it private is still possible
but is now described as what it is: putting the image on the host by hand
before creating the server and again after every release.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 11:55:56 +02:00
12 changed files with 349 additions and 60 deletions
+5
View File
@@ -40,3 +40,8 @@ node_modules/
# Built documentation
/site_build/
# Vite writes these while bundling vite.config.js and removes them again — unless
# the process is killed, which during a hung build is exactly what happens. One
# was committed by accident.
/web/vite.config.js.timestamp-*.mjs
-8
View File
@@ -126,14 +126,6 @@ and the manifest ships in this repository. It runs anywhere Docker does as well.
### With Docker directly
The package is private, and an anonymous pull fails with `No such image` rather
than with a permission error. Log the host's Docker daemon in once, with a GitHub
token carrying the `read:packages` scope:
```bash
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
```
```bash
docker run -d --name verdande \
-p 8080:8080 \
+27 -4
View File
@@ -12,17 +12,40 @@ conversation.
## Connecting it
**Make a token.** **Settings → API tokens → New token**. It is shown once.
**Make a token** under **Settings → API tokens**. The page then shows the
connector address to go with it — token and all — which is the thing to copy:
**Add the connector.** In Claude, add a custom connector with:
```
https://todo.example.dk/mcp?key=vrd_…
```
- **URL**: `https://todo.example.dk/api/v1/mcp`
- **Authentication**: Bearer token, using the token you just made
**Add the connector** in Claude with that address, and nothing else. The dialog
asks for a name and a URL; leave the OAuth fields empty.
!!! note "Why the key is in the address"
The dialog has no field for a bearer token — only OAuth client credentials —
so a header cannot be configured from it. The address *is* the credential
here, exactly as it is for the [calendar feed](caldav.md#subscribing-to-a-feed).
Treat it like a password: anyone holding it reaches what you reach. The
server logs request paths without their query strings, so it does not end up
in the log.
!!! warning "It has to be reachable from the internet"
Claude connects from Anthropic's side, not from your browser. An instance only
reachable on your LAN or over a VPN cannot be used this way.
### With a header instead
Anything that *can* send a header should, and use the API path:
```bash
curl -H "Authorization: Bearer vrd_…" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
https://todo.example.dk/api/v1/mcp
```
Both endpoints run the same server and reach the same things.
## What Claude can do
| Tool | What it does |
+47
View File
@@ -1787,6 +1787,53 @@ paths:
responses:
"302": { description: "Back into the app, with the outcome as a query parameter." }
/mcp:
post:
tags: [integrations]
summary: The MCP endpoint, with the token in the query string.
description: |
The same server as `/api/v1/mcp`, for a client that can only be given a
URL. Claude's custom-connector dialog asks for a name, an address and
OAuth client credentials — there is no field for a bearer token — so the
API path cannot be configured from it.
The key is the only credential accepted here. A session cookie is
ignored: this route sits outside the CSRF check, and honouring a cookie
would make it a cross-site request that acts as whoever is signed in.
security: []
parameters:
- name: key
in: query
required: true
schema: { type: string, example: vrd_… }
description: A personal API token. Treat the whole address as a password.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [jsonrpc, method]
properties:
jsonrpc: { type: string, const: "2.0" }
id: { oneOf: [{ type: string }, { type: integer }] }
method: { type: string }
params: { type: object }
responses:
"200":
description: A JSON-RPC response.
content:
application/json:
schema:
type: object
properties:
jsonrpc: { type: string, const: "2.0" }
id: { oneOf: [{ type: string }, { type: integer }] }
result: { type: object }
error: { type: object }
"202": { description: A notification. No body. }
"401": { $ref: "#/components/responses/Unauthorized" }
/.well-known/caldav:
get:
tags: [integrations]
+46 -16
View File
@@ -3,33 +3,53 @@
verdande ships with a Rune manifest for
[Yggdrasil Panel](https://github.com/kristianwind/yggdrasil).
## Before you start: the registry is private
The image lives at `ghcr.io/kristianwind/verdande` and the package is **private**,
so an anonymous pull fails with a message that reads as though the image does not
exist at all:
## If the image will not pull
```
create container: Error response from daemon:
No such image: ghcr.io/kristianwind/verdande:latest
```
That is a permission error wearing the wrong hat. The Docker daemon **on the
Yggdrasil host** has to be logged in once:
This message is a liar. It does not mean the tag is missing from the registry —
it means the image is not on the host, and the panel's attempt to fetch it failed
without saying so.
The usual cause is a **private** GHCR package. Yggdrasil pulls anonymously: its
`PullImage` calls the Docker SDK with no `RegistryAuth`, so a private image comes
back 401. The pull error is discarded, and `ContainerCreate` — which only ever
looks locally — then reports the image as missing.
!!! warning "`docker login` on the host does not help"
`docker login` is client-side. The daemon stores no credentials; the CLI
sends them itself with each request. Yggdrasil is a different client and
sends none, so logging in changes nothing for the panel.
**Make the package public.** Under
[Packages → verdande → Package settings](https://github.com/users/kristianwind/packages/container/package/verdande)
→ *Change visibility*. The repository stays private; only the built image becomes
readable, which is what the manifest and these pages have always assumed.
Once it is public, press **Start** on the server you already have. There is no
need to delete it and make another: the image reference is read from the rune at
container-create time, and both Start and Restart recreate the container.
To keep the package private you have to put the image on the host yourself,
before creating the server, and again after every release:
```bash
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
sudo docker login ghcr.io -u kristianwind
sudo docker pull ghcr.io/kristianwind/verdande:latest
```
`GHCR_TOKEN` is a GitHub personal access token with the **`read:packages`** scope
and nothing else. The login is stored in `~/.docker/config.json` for whichever
user runs the daemon — if Yggdrasil runs as root or through systemd, log in as
that user, not as yourself.
Run it as the user the Docker daemon runs as — `sudo` if the panel does. The
panel's own pull still fails silently; create simply finds the image already
there.
!!! tip "Or make the package public"
Under **Packages → verdande → Package settings → Change visibility**. The
repository can stay private; only the built image becomes readable. Then no
login is needed anywhere and this section stops applying.
!!! danger "This hides updates"
The panel re-pulls on every recreate, fails quietly, and carries on with the
local image. A new release then *looks* like it rolled out and did not. For
something you intend to update, public is the right answer and host-pull is
the stopgap.
## Installing
@@ -40,6 +60,16 @@ that user, not as yourself.
3. Fill in the settings below.
4. Start it, open the URL, create the first account.
!!! note "Updating the rune is a manual step"
A rune does not update itself when this manifest changes. The panel writes
the rune row from a UI action only — neither a restart nor a timer does it —
so pick up a new manifest with **Runes → verdande → Update**, or install it
again from Browse GitHub.
The catalogue list is cached in memory for ten minutes, so two attempts in a
row can both hand you the old version. Recent panel versions show the list's
age with a *fetch again* link.
## Settings
| Setting | What to put |
+6 -9
View File
@@ -5,15 +5,12 @@ cache, no queue and no second process.
## Docker
!!! warning "Log in to the registry first"
The package is private, and an anonymous pull fails with `No such image`
rather than with a permission error. Once per host:
```bash
echo "$GHCR_TOKEN" | docker login ghcr.io -u kristianwind --password-stdin
```
`GHCR_TOKEN` is a GitHub token with the `read:packages` scope.
!!! note "If the pull is refused"
The GHCR package is public, so this needs no credentials. If it is ever set
back to private, `docker login ghcr.io` with a token carrying the
`read:packages` scope before pulling — and see
[the Rune page](rune.md#if-the-image-will-not-pull), because a private image
fails differently and much more confusingly under Yggdrasil.
```bash
docker run -d --name verdande \
+109
View File
@@ -771,3 +771,112 @@ func TestUpdateNoticeIsForAdministratorsOnly(t *testing.T) {
t.Error("the current version is hidden from members")
}
}
// --- MCP with the key in the URL --------------------------------------------------
// Claude's custom-connector dialog takes a URL and nothing else — no bearer
// token — so this is the only shape that can be configured from it.
func TestMCPAcceptsAKeyInTheQuery(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var userID string
err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID)
if err != nil {
t.Fatal(err)
}
key := ts.apiToken(t, userID)
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status %d, want 200", resp.StatusCode)
}
var decoded map[string]any
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
t.Fatalf("the response was not JSON-RPC: %v", err)
}
result, _ := decoded["result"].(map[string]any)
if result == nil || result["protocolVersion"] == nil {
t.Errorf("no protocol version was announced: %v", decoded)
}
}
// The endpoint that broke it: /mcp used not to exist, so it fell through to the
// SPA fallback and answered 200 with the app shell. A connector pointed at it
// reported success and then failed to parse a page of HTML.
func TestMCPWithoutAKeyIsNotTheAppShell(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, url := range []string{ts.URL + "/mcp", ts.URL + "/mcp?key=not-a-verdande-token"} {
resp, err := http.Post(url, "application/json", strings.NewReader(`{"jsonrpc":"2.0","id":1}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("%s: status %d, want 401", url, resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("%s: content-type %q — a connector cannot read that", url, ct)
}
}
}
// A revoked key stops working, like any other.
func TestMCPKeyStopsWorkingWhenRevoked(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var userID string
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil {
t.Fatal(err)
}
key := ts.apiToken(t, userID)
var id string
if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM api_tokens LIMIT 1`).Scan(&id); err != nil {
t.Fatal(err)
}
if err := ts.db.DeleteAPIToken(t.Context(), userID, id); err != nil {
t.Fatal(err)
}
resp, err := http.Post(ts.URL+"/mcp?key="+key, "application/json",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a revoked key still works: status %d", resp.StatusCode)
}
}
// The session cookie must NOT work here. This route sits outside the CSRF check,
// so a cookie-authenticated POST would be a cross-site request that acts as
// whoever is signed in — the exact hole the key-only rule exists to close.
func TestMCPWithKeyIgnoresTheSessionCookie(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// ts.client holds the session from bootstrap. No key in the URL.
resp, err := ts.client.Post(ts.URL+"/mcp", "application/json",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a signed-in cookie authenticated a POST outside the CSRF check: status %d",
resp.StatusCode)
}
}
+38
View File
@@ -3,11 +3,13 @@ package httpapi
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/mcp"
"github.com/kristianwind/verdande/internal/quickadd"
"github.com/kristianwind/verdande/internal/store"
@@ -124,6 +126,42 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response)
}
// handleMCPWithKey is the same endpoint for a client that can only be given a
// URL: `POST /mcp?key=vrd_…`.
//
// Claude's custom-connector dialog asks for a name, an address and — under
// advanced settings — an OAuth client id and secret. There is no field for a
// bearer token, so /api/v1/mcp cannot be configured from it however correct the
// address is. The calendar feed already carries its credential in the URL for
// the same reason, and the request logger records the path without the query, so
// the key does not end up in the log.
//
// The key is the *only* thing accepted here. Reading a session cookie instead
// would make this a state-changing POST outside the CSRF check — which is to say
// a cross-site request that acts as whoever is signed in.
func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")
if key == "" || !auth.IsAPIToken(key) {
// 401 rather than 404: unlike a task, the existence of this endpoint is
// not a secret — it is written in the documentation.
writeError(w, http.StatusUnauthorized, CodeUnauthorized,
"add ?key=<api token> to the URL")
return
}
user, err := s.db.UserByAPIToken(r.Context(), key)
if err != nil {
if !errors.Is(err, store.ErrNotFound) {
s.internal(w, "mcp key", err)
return
}
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid")
return
}
s.handleMCP(w, r.WithContext(withUser(r.Context(), user)))
}
// --- tool implementations ------------------------------------------------------------
func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) {
+12
View File
@@ -90,6 +90,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// authenticated by the token in the recipient address.
r.Post("/inbound/mail", s.handleInboundMail)
// MCP with the token in the query string, for clients that cannot send a
// header. Claude's custom-connector dialog takes a URL and nothing else, so
// /api/v1/mcp — which wants Authorization: Bearer — cannot be configured from
// it at all. Same reasoning as the calendar feed above: a client that cannot
// be told to send a header means the token in the URL is the credential.
//
// Outside /api/v1 deliberately, because it must not be reachable with a
// session cookie. A cookie-authenticated POST with no CSRF check is a
// cross-site request waiting to happen; handleMCPWithKey accepts the key and
// nothing else.
r.Post("/mcp", s.handleMCPWithKey)
// Google sends the browser here after consent. Outside /api/v1 because it is a
// top-level navigation rather than a fetch, but still behind the session — the
// tokens it exchanges belong to whoever is signed in.
+18
View File
@@ -179,3 +179,21 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
expect(worker.ok()).toBeTruthy();
expect(await worker.text()).toContain("addEventListener('push'");
});
/**
* The MCP connector address must not fall through to the app shell.
*
* It did: `/mcp` was not a route, so the SPA fallback answered 200 with a page
* of HTML. A connector pointed at it reported a successful connection and then
* failed to parse the response — which looks like a broken client rather than a
* missing route. A 401 in JSON is the honest answer.
*/
test('MCP-adressen svarer JSON, ikke app-skallen', async ({ request }) => {
const response = await request.post('/mcp', {
data: { jsonrpc: '2.0', id: 1, method: 'initialize' }
});
expect(response.status(), 'uden nøgle skal den afvise, ikke servere siden').toBe(401);
expect(response.headers()['content-type']).toContain('application/json');
expect((await response.json()).code).toBe('unauthorized');
});
@@ -20,6 +20,10 @@
/** The plaintext of the token just minted. Held only in this variable. */
let fresh = $state(null);
let copied = $state(false);
let connectorCopied = $state(false);
/** What Claude's connector dialog needs, which is a URL and nothing else. */
let connectorURL = $derived(fresh ? `${location.origin}/mcp?key=${fresh.token}` : '');
$effect(() => {
api
@@ -59,6 +63,15 @@
}
}
async function copyConnector() {
try {
await navigator.clipboard.writeText(connectorURL);
connectorCopied = true;
} catch {
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
}
}
async function revoke(token) {
if (!confirm(`Tilbagekald "${token.name}"? Alt, der bruger den, holder op med at virke.`))
return;
@@ -100,6 +113,22 @@
{#if copied}<span class="saved">Kopieret.</span>{/if}
<button class="secondary" onclick={() => (fresh = null)}>Færdig</button>
</div>
<!-- Shown here and nowhere else, because it contains the token, and the
token exists only in this moment. Claude's connector dialog takes a
URL and nothing else — there is no field for a header — so this is
the form it has to be in. -->
<div class="also">
<p class="hint">
Skal du bruge den som <a href="/indstillinger/integrationer">MCP-forbindelse</a>
i Claude? Så er det denne adresse, dialogen skal have:
</p>
<p class="mono value">{connectorURL}</p>
<div class="row">
<button class="secondary" onclick={copyConnector}>Kopiér adressen</button>
{#if connectorCopied}<span class="saved">Kopieret.</span>{/if}
</div>
</div>
</div>
{/if}
@@ -194,6 +223,18 @@
user-select: all;
}
.also {
display: flex;
flex-direction: column;
gap: var(--s2);
padding-top: var(--s3);
border-top: 1px solid var(--accent);
}
.also a {
color: inherit;
}
.what {
display: flex;
flex-direction: column;
@@ -1,23 +0,0 @@
// vite.config.js
import { sveltekit } from "file:///Users/kw/Documents/Code/ToDoApp/web/node_modules/@sveltejs/kit/src/exports/vite/index.js";
var vite_config_default = {
plugins: [sveltekit()],
server: {
port: 5173,
// In development the app runs on 5173 and the API on 8080. Proxying rather
// than pointing fetch at another origin keeps the session cookie
// first-party, which is what production looks like — otherwise every
// cookie and CSRF behaviour would differ between dev and the real thing.
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: false,
ws: true
}
}
}
};
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMva3cvRG9jdW1lbnRzL0NvZGUvVG9Eb0FwcC93ZWJcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9Vc2Vycy9rdy9Eb2N1bWVudHMvQ29kZS9Ub0RvQXBwL3dlYi92aXRlLmNvbmZpZy5qc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vVXNlcnMva3cvRG9jdW1lbnRzL0NvZGUvVG9Eb0FwcC93ZWIvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgeyBzdmVsdGVraXQgfSBmcm9tICdAc3ZlbHRlanMva2l0L3ZpdGUnO1xuXG5leHBvcnQgZGVmYXVsdCB7XG5cdHBsdWdpbnM6IFtzdmVsdGVraXQoKV0sXG5cdHNlcnZlcjoge1xuXHRcdHBvcnQ6IDUxNzMsXG5cdFx0Ly8gSW4gZGV2ZWxvcG1lbnQgdGhlIGFwcCBydW5zIG9uIDUxNzMgYW5kIHRoZSBBUEkgb24gODA4MC4gUHJveHlpbmcgcmF0aGVyXG5cdFx0Ly8gdGhhbiBwb2ludGluZyBmZXRjaCBhdCBhbm90aGVyIG9yaWdpbiBrZWVwcyB0aGUgc2Vzc2lvbiBjb29raWVcblx0XHQvLyBmaXJzdC1wYXJ0eSwgd2hpY2ggaXMgd2hhdCBwcm9kdWN0aW9uIGxvb2tzIGxpa2UgXHUyMDE0IG90aGVyd2lzZSBldmVyeVxuXHRcdC8vIGNvb2tpZSBhbmQgQ1NSRiBiZWhhdmlvdXIgd291bGQgZGlmZmVyIGJldHdlZW4gZGV2IGFuZCB0aGUgcmVhbCB0aGluZy5cblx0XHRwcm94eToge1xuXHRcdFx0Jy9hcGknOiB7XG5cdFx0XHRcdHRhcmdldDogJ2h0dHA6Ly9sb2NhbGhvc3Q6ODA4MCcsXG5cdFx0XHRcdGNoYW5nZU9yaWdpbjogZmFsc2UsXG5cdFx0XHRcdHdzOiB0cnVlXG5cdFx0XHR9XG5cdFx0fVxuXHR9XG59O1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUE4UixTQUFTLGlCQUFpQjtBQUV4VCxJQUFPLHNCQUFRO0FBQUEsRUFDZCxTQUFTLENBQUMsVUFBVSxDQUFDO0FBQUEsRUFDckIsUUFBUTtBQUFBLElBQ1AsTUFBTTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUEsSUFLTixPQUFPO0FBQUEsTUFDTixRQUFRO0FBQUEsUUFDUCxRQUFRO0FBQUEsUUFDUixjQUFjO0FBQUEsUUFDZCxJQUFJO0FBQUEsTUFDTDtBQUFBLElBQ0Q7QUFBQSxFQUNEO0FBQ0Q7IiwKICAibmFtZXMiOiBbXQp9Cg==