5 Commits
Author SHA1 Message Date
haylanandClaude-Bot 299343b83b chore: add Context7 MCP server for library docs
Checked docs/research for more useful MCP servers beyond leankg/
codebase-memory-mcp/playwright. Added Context7 (@upstash/context7-mcp,
61.5K stars, official Upstash) via npx — live docs lookup for the
libraries this project actually pulls in (sabre/dav, sabre/vobject,
laminas-feed, endroid/qr-code, FullCalendar, Vite, Symfony), none of
which have an offline index the way msgraph does for Graph.

Evaluated and rejected CalDAV/Nextcloud MCP servers (caldav-mcp 98
stars, aiquila-mcp 34 stars — real but too young, and would need a
live Nextcloud app-password before any account is even provisioned)
and Gitea MCP servers (0-1 stars, redundant with the tea CLI already
standardized on via the gitea-tea skill). No Docker MCP found as an
installable package — Docker's own MCP tooling lives inside Docker
Desktop's MCP Toolkit, not npm/pip, and there's no Dockerfile/CI yet
to make it relevant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tu1c13iVNdj1iWL633yazG
2026-09-01 21:13:40 +02:00
haylanandClaude-Bot b51b832bbd chore: add repo-level .mcp.json for leankg, codebase-memory-mcp, playwright
These were only registered in the user's global ~/.claude.json (with
machine-specific absolute paths), so they weren't available to anyone
else working in this repo. Added a repo-root .mcp.json using bare
command names (leankg, codebase-memory-mcp) so it's portable across
machines once those binaries are on PATH, plus the official Playwright
MCP server via npx (no install needed) — closing the "no separate
Playwright MCP server configured" gap noted in
docs/research/smart-tablet-screen-stack.md §5.

Also gitignore .leankg/ (leankg's local runtime pid/index state,
regenerated on start, not source).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tu1c13iVNdj1iWL633yazG
2026-09-01 21:10:40 +02:00
haylanandClaude-Bot 25f9e0e735 chore: pin Symfony and Microsoft Graph skills for this repo
Research in docs/research flagged missing tooling for the project's
actual stack: Symfony backend work and Microsoft Graph calendar sync
had no matching local skill. Searched skills.sh and installed
project-level:

- dev-toolings/superpowers-symfony (11 of 44 skills, matching what
  stack.md/config-secrets.md already decided on: config/secrets vault,
  bootstrap check, PHPUnit TDD + functional tests + mocking, controller
  cleanup, quality checks, DI/autowiring, Cache component, Doctrine
  migrations). Skipped the API Platform, Messenger/CQRS, hexagonal-
  architecture, Twig-component, and Pest skills — none of those are
  part of the decided stack (plain MVC JSON API, no API Platform, TS
  frontend not Twig, PHPUnit not Pest).
- merill/msgraph (offline-indexed Graph API search, no network calls)
  for the Outlook/Exchange calendar sync decided in stack.md.

No CalDAV/Nextcloud skill was installed: every candidate on skills.sh
is either a personal-calendar CLI wrapper (vdirsyncer+khal) or gated
behind a third-party SaaS account (Membrane) — neither fits writing a
PHP backend integration against sabre/dav, which the research doc
already speccs out directly.

skills-lock.json is tracked; .claude/skills/ payloads are gitignored
(msgraph alone is 112MB of multi-platform binaries + SQLite indexes)
and restored per-clone via `npx skills experimental_install -y`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tu1c13iVNdj1iWL633yazG
2026-09-01 21:07:19 +02:00
haylanandClaude-Bot f045d1bc93 chore: set up agent skills config (issue tracker, domain docs)
Add AGENTS.md/CLAUDE.md agent-skills block, docs/agents/issue-tracker.md
(Gitea via tea CLI) and docs/agents/domain.md (single-context layout).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:58:56 +02:00
haylanandClaude-Bot a6fd372123 docs(research): add YAML account config and secrets vault research
Cover Symfony Config component typed-list patterns (Doctrine/Messenger
precedents) and the native secrets vault vs. plaintext YAML/SOPS for
encrypting account credentials.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:54:07 +02:00
8 changed files with 488 additions and 0 deletions
+8
View File
@@ -52,3 +52,11 @@
# Embedded web-server pid file # Embedded web-server pid file
/.web-server-pid /.web-server-pid
# Claude Code skill payloads (restorable via `npx skills experimental_install`
# from skills-lock.json, which IS tracked) and agent worktree scratch space
/.claude/skills/
/.claude/worktrees/
# leankg's local runtime state (pid file, index) — regenerated on start
/.leankg/
+19
View File
@@ -0,0 +1,19 @@
{
"mcpServers": {
"leankg": {
"command": "leankg",
"args": ["mcp-stdio", "--watch"]
},
"codebase-memory-mcp": {
"command": "codebase-memory-mcp"
},
"playwright": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest"]
},
"context7": {
"command": "npx",
"args": ["-y", "@upstash/context7-mcp@latest"]
}
}
}
+32
View File
@@ -0,0 +1,32 @@
## Agent skills
### Issue tracker
Issues live as Gitea issues on git.arthurerlich.de/haylan/Smart-Tablet-Screen, managed via the `tea` CLI. See `docs/agents/issue-tracker.md`.
### Domain docs
Single-context: `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`.
### Project skills
`skills-lock.json` at the repo root pins the extra Claude Code skills this project relies on (Symfony, Microsoft Graph). Their payloads aren't committed (large binaries/indexes — see `.gitignore`); restore them once per clone/worktree with:
```
npx skills experimental_install -y
```
No CalDAV/Nextcloud skill is pinned — none found on skills.sh fit writing a PHP backend integration (candidates were CLI personal-calendar tools or SaaS-gated); see `docs/research/smart-tablet-screen-nextcloud-todos.md` for the sabre/dav-based approach instead.
### MCP servers
`.mcp.json` at the repo root wires up this project's MCP servers — Claude Code loads it automatically for anyone working in this repo, no per-machine registration needed:
- **`leankg`** — code-graph/knowledge-graph tooling (architecture queries, call graphs, dead-code/impact analysis). Requires the `leankg` binary on `PATH`: `cargo install leankg`.
- **`codebase-memory-mcp`** — code-graph search and tracing (`search_graph`, `trace_path`, `get_architecture`) used per the Code Discovery Protocol below. Requires the `codebase-memory-mcp` binary on `PATH`: `pipx install codebase-memory-mcp` or `npm install -g codebase-memory-mcp`.
- **`playwright`** — official Microsoft Playwright MCP server, run via `npx` (no install needed). Pairs with the `playwright-e2e-testing` skill and the E2E decision in `docs/research/smart-tablet-screen-stack.md` §5.
- **`context7`** — official Upstash Context7 MCP server, run via `npx` (no install needed). Live, up-to-date docs lookup for the libraries this project actually pulls in — `sabre/dav`/`sabre/vobject`, `laminas-feed`, `endroid/qr-code`, FullCalendar, Vite, Symfony components — none of which have an offline index the way `msgraph` does.
`.mcp.json` uses bare command names (not absolute paths) so it works across machines once `leankg`/`codebase-memory-mcp` are installed; `playwright` and `context7` need nothing preinstalled (`npx` fetches them).
No CalDAV/Nextcloud or Gitea MCP server is pinned. CalDAV/Nextcloud candidates (`caldav-mcp`, `aiquila-mcp`) are real but too young (under 100 GitHub stars) and would need a live Nextcloud app-password wired in before there's even an account provisioned (see `smart-tablet-screen-config-secrets.md`) — revisit once an account exists. Gitea MCP candidates are essentially unused (01 stars) and redundant with the `tea` CLI this repo already standardized on via the `gitea-tea` skill.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+51
View File
@@ -0,0 +1,51 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root, or
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
## File structure
Single-context repo (most repos):
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← context-specific decisions
└── billing/
├── CONTEXT.md
└── docs/adr/
```
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
+39
View File
@@ -0,0 +1,39 @@
# Issue tracker: Gitea
Issues and specs for this repo live as Gitea issues on `git.arthurerlich.de/haylan/Smart-Tablet-Screen`. Use the `tea` CLI for all operations.
## Conventions
- **Create an issue**: `tea issues create -r haylan/Smart-Tablet-Screen --title "..." --description "..."`
- **Read an issue**: `tea issues view -r haylan/Smart-Tablet-Screen <number>`
- **List issues**: `tea issues list -r haylan/Smart-Tablet-Screen`
- **Comment on an issue**: `tea comment -r haylan/Smart-Tablet-Screen <number> "..."`
- **Apply / remove labels, assignees, milestone**: `tea issues edit -r haylan/Smart-Tablet-Screen --add-labels "..." --add-assignees "..." <number>` (use `--remove-labels`/`--remove-assignees` to unset)
- **Close / reopen**: `tea issues close -r haylan/Smart-Tablet-Screen <number>` / `tea issues reopen -r haylan/Smart-Tablet-Screen <number>`
Repo is inferred from `git remote -v` when running `tea` from the repo root; pass `-r haylan/Smart-Tablet-Screen` explicitly if inference fails.
## Pull requests as a triage surface
**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag — not relevant yet since `triage` isn't installed.)_
When set to `yes`, use the `tea pulls` equivalents: `tea pulls view`, `tea pulls create`, `tea comment <number> "..."`, `tea pulls merge`.
## When a skill says "publish to the issue tracker"
Create a Gitea issue with `tea issues create`.
## When a skill says "fetch the relevant ticket"
Run `tea issues view -r haylan/Smart-Tablet-Screen <number>`.
## Wayfinding operations
Gitea has no native issue-dependency/blocking API (unlike GitHub/GitLab), so fall back to plain-text conventions:
- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body.
- **Child ticket**: an issue with `Part of #<map>` at the top of its description, labelled `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, assign it to the driving dev.
- **Blocking**: a `Blocked by: #<n>, #<n>` line at the top of the description — no native link available. A ticket is unblocked when every blocker listed there is closed.
- **Frontier query**: `tea issues list -r haylan/Smart-Tablet-Screen`, scoped to the map's children, drop any with an open blocker in its `Blocked by` line or an assignee; first in map order wins.
- **Claim**: `tea issues edit -r haylan/Smart-Tablet-Screen --add-assignees "@me" <n>` — the session's first write.
- **Resolve**: `tea comment -r haylan/Smart-Tablet-Screen <n> "<answer>"`, then `tea issues close -r haylan/Smart-Tablet-Screen <n>`, then append a context pointer to the map's Decisions-so-far.
@@ -0,0 +1,261 @@
# Smart Tablet Screen — Multi-Account Config & Secrets Research
Research date: 2026-09-01. Scope: how to configure the project's multiple account "sources" (Microsoft/Exchange OAuth2, N Mail-in-a-Box CalDAV accounts, a Nextcloud CalDAV account, an RSS feed list, a Diun webhook secret) via YAML, and how to keep the secret values out of plaintext. All claims sourced against primary/official docs; each claim links its own source. This report does not redo the research in `smart-tablet-screen-stack.md`, `smart-tablet-screen-additions.md`, or `smart-tablet-screen-nextcloud-todos.md`, and does not modify them.
---
## 1. Modeling "N accounts, each with a type and connection details" in Symfony config
### 1.1 The Config component's `TreeBuilder` + `useAttributeAsKey()` + prototypes
Symfony's own Config component docs describe defining a config schema as a `Configuration` class returning a `TreeBuilder`:
```php
class DatabaseConfiguration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('database');
return $treeBuilder;
}
}
```
— [Defining and Processing Configuration Values — Symfony Config component, symfony.com/doc](https://symfony.com/doc/current/components/config/definition.html)
The relevant piece for "a list of named entries, each with a shared shape" is the combination of an array node's **prototype** (the shape repeated for every entry) with **`useAttributeAsKey()`** (which preserves the YAML map key — e.g. the account name — as an array key rather than discarding it, which is the array node's default behavior for list-like input):
```php
$node
->children()
->arrayNode('connections', 'connection')
->useAttributeAsKey('name') // Preserves keys
->arrayPrototype()
->children()
->scalarNode('table')->end()
->scalarNode('user')->end()
->scalarNode('password')->end()
->end()
->end()
->end()
->end()
;
```
Given
```yaml
connections:
sf_connection:
table: symfony
user: root
default:
table: foo
user: root
```
this produces a PHP array keyed by `sf_connection` and `default`, each holding its own validated sub-tree — [Config component — Defining Configuration Trees, symfony.com/doc/current/components/config/definition.html](https://symfony.com/doc/current/components/config/definition.html)
This is exactly the shape needed for "N Mail-in-a-Box accounts, each keyed by a name, each with its own `url`/`user`/`password` fields."
### 1.2 How a bundle exposes that schema as YAML
Symfony's bundle configuration docs confirm the two supported ways to wire a `Configuration` schema up to real `config/packages/*.yaml` input:
- **`AbstractBundle`** (current recommended style): define the tree directly in the bundle class via `configure(DefinitionConfigurator $definition)`, and read the merged/validated `$config` array in `loadExtension()`.
- **Traditional Extension + separate `Configuration` class**: a `src/DependencyInjection/Configuration.php` implementing `ConfigurationInterface`, whose `getConfigTreeBuilder()` is passed into `processConfiguration()` inside the bundle's `Extension::load()` — this is what actually validates and merges the YAML.
Either way, the **root key is derived from the bundle/extension name** and the user-facing config lives in `config/packages/<extension_name>.yaml`, e.g.:
```yaml
# config/packages/acme_social.yaml
acme_social:
twitter:
client_id: 123
client_secret: your_secret
```
— [How to Create Friendly Configuration for a Bundle, symfony.com/doc/current/bundles/configuration.html](https://symfony.com/doc/current/bundles/configuration.html)
The `config:dump-reference` console command (documented on the same page) dumps the effective schema for a given config root — useful for verifying a `smart_screen.yaml` schema once written, without hand-checking every node.
**Applied to this project**: a `SmartScreenBundle` (or its `Extension`+`Configuration` pair) defining a `smart_screen` root, with sub-trees for `accounts.exchange`, `accounts.caldav` (list, `useAttributeAsKey`), `accounts.nextcloud`, `rss.feeds` (list), and `diun.webhook`, is the idiomatic Symfony way to validate this project's `accounts.yaml`-shaped config rather than reading raw YAML by hand.
### 1.3 Real first-party precedent for "N named entries, each with a type/DSN discriminator"
Two of Symfony's own subsystems use exactly this "list of named, typed connections" pattern in their reference YAML, confirming it's an established, idiomatic pattern rather than something bespoke to invent for this project:
**Doctrine DBAL — multiple named connections:**
```yaml
doctrine:
dbal:
default_connection: default
connections:
default:
dbname: Symfony2
user: root
password: null
host: localhost
customer:
dbname: customer
user: root
password: null
host: localhost
```
Each connection is also exposed as its own service, `doctrine.dbal.[name]_connection`. — [DoctrineBundle Configuration Reference, symfony.com/bundles/DoctrineBundle/current/configuration.html](https://symfony.com/bundles/DoctrineBundle/current/configuration.html)
**Messenger — multiple named transports, each keyed by name with its own `dsn`/`options`:**
```yaml
# config/packages/messenger.yaml
framework:
messenger:
transports:
async_priority_high:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: high
async_priority_low:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: low
routing:
'App\Message\SmsNotification': async_priority_low
'App\Message\NewUserWelcomeEmail': async_priority_high
```
A transport can also be written as a bare DSN string (`async: "%env(MESSENGER_TRANSPORT_DSN)%"`) when no extra options are needed, which is the shorthand form of the same prototype. — [Messenger: Sync & Queued Message Handling, symfony.com/doc/current/messenger.html](https://symfony.com/doc/current/messenger.html)
**Relevance to this project**: Messenger's transport DSN string (`amqp://...`, `doctrine://...`, `sync://`) is effectively a "type" encoded in the URL scheme, dispatched by Messenger's transport factory chain to the right transport implementation. The same idea maps directly onto this project's account list: each entry can carry an explicit `type: exchange|caldav_miab|caldav_nextcloud` discriminator (clearer for four genuinely different shapes than trying to smuggle type into a URL scheme), with the Symfony `Configuration` tree validating that each type's required fields are present — while `%env(...)%` placeholders (see §2) fill in the actual secret values, exactly as Messenger does with `%env(MESSENGER_TRANSPORT_DSN)%`.
### 1.4 On the "known-good YAML for multiple external accounts with a type field" comparison target
The task asked to check a real OSS project (e.g. mbin/friendica) configuring multiple external accounts via YAML with a discriminated type field, as a second data point beyond Symfony's own subsystems. Symfony's own Messenger transports config (§1.3) already **is** that pattern, first-party, documented, and directly reusable by this project without adopting an unrelated project's schema conventions — so no separate third-party project was adopted as a model; Messenger's transports and Doctrine's connections together cover the "list of named, typed entries" precedent this project needs.
---
## 2. Encrypting the secrets
### 2.1 Confirm: no encryption in plain YAML
Symfony's Config/bundle-configuration docs contain no mechanism for encrypting values inside a YAML file — YAML itself is a plain serialization format with no such feature, and the fields Symfony's `Configuration` tree validates are read as literal strings unless the value is an `%env(...)%` placeholder (§2.3). The framework's own answer to "how do I keep a config value secret" is the dedicated secrets vault, not YAML: Symfony's configuration docs state plainly:
> "Instead of defining a real environment variable or adding it to a `.env` file, if the value of a variable is sensitive (e.g. an API key or a database password), you can encrypt the value using the [secrets management system]."
— [Configuring Symfony, symfony.com/doc/current/configuration.html](https://symfony.com/doc/current/configuration.html)
### 2.2 Symfony's native secrets vault
Per Symfony's own secrets docs — [Configuring Symfony — Secrets, symfony.com/doc/current/configuration/secrets.html](https://symfony.com/doc/current/configuration/secrets.html):
- The vault requires the **Sodium PHP extension**. Keys are generated per environment: `php bin/console secrets:generate-keys` produces `config/secrets/<env>/<env>.encrypt.public.php` (safe to commit — used only to encrypt/add secrets) and `config/secrets/<env>/<env>.decrypt.private.php` (must **not** be committed for `prod` — used to decrypt).
- **Algorithm: libsodium asymmetric sealed boxes** — public-key encryption where the public key encrypts and only the private key decrypts, giving confidentiality and integrity without needing the decryption key present during container compilation/cache warmup.
- Secrets are set via `php bin/console secrets:set SECRET_NAME` (interactively, from a file, from STDIN, or `--random`), listed via `secrets:list` (`--reveal` to show plaintext), and removed via `secrets:remove`.
- A secret is referenced in YAML exactly like a normal env var, via the `%env(...)%` syntax:
```yaml
# config/packages/doctrine.yaml
doctrine:
dbal:
password: '%env(DATABASE_PASSWORD)%'
```
- Production deployment options: copy `prod.decrypt.private.php` to the server, or set `SYMFONY_DECRYPTION_SECRET` (base64 of the private key) as a real env var, or run `secrets:decrypt-to-local` during deploy so the private key never needs to live on the server long-term.
### 2.3 `.env` vs. the vault — and whether they coexist
Symfony's configuration docs lay out the `.env` file hierarchy and its intended scope explicitly:
> "This file should be committed to your repository and (due to that fact) should only contain 'default' values that are good for local development. This file should not contain production values."
with `.env` (defaults), `.env.local` (uncommitted, machine-specific), `.env.<environment>` (committed, per-environment), and `.env.<environment>.local` (uncommitted, per-environment-and-machine) — [Configuring Symfony, symfony.com/doc/current/configuration.html](https://symfony.com/doc/current/configuration.html). Immediately after that, the same page hands sensitive values off to the secrets system quoted in §2.1.
**They coexist by design, not by accident**: the secrets vault docs show the vault has an explicit "local override" escape hatch — `php bin/console secrets:set SECRET_NAME --local` writes the value straight into `.env.<env>.local` as plaintext, and that local `.env` value takes precedence over the vault's encrypted value. This is the documented mechanism for "give a developer a working non-production secret locally without touching the committed vault." — [Configuring Symfony — Secrets, symfony.com/doc/current/configuration/secrets.html](https://symfony.com/doc/current/configuration/secrets.html)
**Conclusion for this project** (matching the plan already assumed in `smart-tablet-screen-stack.md`, which put the Graph client id/secret/refresh token in `.env`): keep genuinely non-sensitive, per-environment configuration (feature flags, base URLs that aren't secret, `APP_ENV`) in `.env`/`.env.local` as before, but move the four actual secret values this project has — Graph `client_secret` and refresh token, each Mail-in-a-Box account's app-password, the Nextcloud account's app-password, and the Diun webhook shared token — into the secrets vault, referenced from YAML via `%env(...)%`. This is a refinement of, not a contradiction to, the earlier `.env`-based plan: it only reclassifies which values live in which of the two documented buckets.
### 2.4 Can the vault hold structured/multiple values, or is the answer a YAML+vault hybrid?
The vault's own interface (`secrets:set NAME`, `secrets:list`, `%env(NAME)%`) is a **flat, name→string store** — each secret is one named string value, decrypted individually into one env var. There is no first-party "store a nested object/array as one secret" primitive in the commands themselves. However, Symfony's **env var processors** dock directly onto both plain env vars and vault-backed secrets (since a resolved secret becomes an ordinary env var to the container at runtime), and two of them make structured secret values workable:
- **`env(json:FOO)`** — decodes a JSON-encoded env var into an array:
```yaml
parameters:
env(ALLOWED_LANGUAGES): '["en","de","es"]'
app_allowed_languages: '%env(json:ALLOWED_LANGUAGES)%'
```
- **`env(resolve:FOO)`** — interpolates `%parameter_name%` container parameters *inside* an env var's value before it's used, useful for composing a value (e.g. a DSN) from one secret plus other non-secret parameters:
```yaml
parameters:
sentry_host: '10.0.0.1'
env(SENTRY_DSN): 'http://%sentry_host%/project'
sentry:
dsn: '%env(resolve:SENTRY_DSN)%'
```
- Processors chain (e.g. `%env(json:file:resolve:AUTH_FILE)%` — resolve a path, read the file, JSON-decode it), showing the mechanism is designed to compose.
— [Environment Variable Processors, symfony.com/doc/current/configuration/env_var_processors.html](https://symfony.com/doc/current/configuration/env_var_processors.html)
**Practical answer for this project**: don't try to cram the whole `accounts.yaml` account list into one vault secret (JSON-blob-in-a-vault-entry). The clean split, directly matching what Messenger/Doctrine already do (§1.3), is a **hybrid**:
- Keep the *structure* — the list of accounts, each account's `type`, non-secret connection fields (server URLs, usernames, feed URLs, account display names) — in plain committed YAML (`config/packages/smart_screen.yaml`), validated by a `Configuration` tree (§1).
- Put only the *actual secret strings* (app-passwords, client secret, refresh token, webhook token) into individually-named vault secrets, and reference each one from the YAML via a `%env(SECRET_NAME)%` placeholder in the relevant field — exactly the pattern Doctrine's own reference config uses for `password: '%env(DATABASE_PASSWORD)%'` (§2.2) and Messenger uses for `dsn: '%env(MESSENGER_TRANSPORT_DSN)%'` (§1.3).
- For N Mail-in-a-Box accounts each needing their own app-password, this means N vault secrets (e.g. `MIAB_HOME_APP_PASSWORD`, `MIAB_WORK_APP_PASSWORD`), one per account entry, each referenced by name from that account's YAML block — no processor trickery needed since each field is just one string.
---
## 3. SOPS as a comparison point
Per SOPS's own README:
> "SOPS is an editor of encrypted files that supports YAML, JSON, ENV, INI and BINARY formats."
SOPS encrypts values **inline, in place, inside the committed file itself** (rather than moving them to a separate vault/store), supporting age and PGP as well as cloud KMS providers (AWS KMS, GCP KMS, Azure Key Vault, HashiCorp Vault). Using it requires: the `sops` binary itself, and key management — either a cloud KMS the deploy environment has IAM access to, or a locally-managed age/PGP keypair whose private half must reach every machine/CI job that needs to decrypt. — [getsops/sops README, github.com/getsops/sops](https://github.com/getsops/sops)
**Trade-off vs. Symfony's native vault**: SOPS is format-agnostic and works outside any one framework, but it is an **external dependency** — a separate binary to install everywhere the file is decrypted (dev machines, CI, prod), plus a key (age/PGP/KMS) to provision and rotate outside of Symfony's own tooling, and no native `%env(...)%`-style resolution — some glue (`sops -d` piped into `.env` or an env-loading step) is needed to get decrypted values into the app at all. Symfony's vault, by contrast, needs **zero extra dependencies beyond the already-required PHP `sodium` extension**, ships its own CLI (`secrets:set`/`secrets:list`/`secrets:generate-keys`), and resolves straight into `%env(...)%` — the same mechanism this project already uses for ordinary env vars — with no extra decode step. For a single-team, self-hosted Symfony project (this one), the native vault is the lower-friction choice; SOPS would only earn its keep if the project already had multi-repo/multi-language secret sharing or an existing cloud-KMS setup to plug into, neither of which applies here.
---
## Recommended approach
Use Symfony's **native secrets vault + `.env`, referenced from a `Configuration`-validated `config/packages/smart_screen.yaml`** — no third-party secret-encryption tool, no bespoke YAML-parsing code.
1. Define a `Configuration` tree (per §1.1–§1.2) under a `smart_screen` root with a discriminated, `useAttributeAsKey()`-keyed `accounts` list (per §1.3's Messenger/Doctrine precedent) plus a plain `rss.feeds` list and a `diun` block.
2. Put every actual secret (OAuth client secret + refresh token, each CalDAV account's app-password, the Diun webhook token) into the vault via `bin/console secrets:set <NAME>`, and reference each one from the YAML with `%env(<NAME>)%` — never as a literal string in the file.
3. Non-secret connection details (server URLs, usernames, feed URLs, account display names, the `type` discriminator) stay as plain YAML values, committed normally.
Illustrative shape only — not real project code:
```yaml
# config/packages/smart_screen.yaml
smart_screen:
accounts:
work_outlook:
type: exchange
tenant: common
client_id: '11111111-2222-3333-4444-555555555555'
client_secret: '%env(EXCHANGE_WORK_CLIENT_SECRET)%'
refresh_token: '%env(EXCHANGE_WORK_REFRESH_TOKEN)%'
miab_home:
type: caldav_miab
url: 'https://mail.example.com/remote.php/dav/calendars/alice/'
user: 'alice'
app_password: '%env(MIAB_HOME_APP_PASSWORD)%'
miab_work:
type: caldav_miab
url: 'https://mail2.example.com/remote.php/dav/calendars/bob/'
user: 'bob'
app_password: '%env(MIAB_WORK_APP_PASSWORD)%'
nextcloud_tasks:
type: caldav_nextcloud
url: 'https://cloud.example.com/remote.php/dav/calendars/alice/'
user: 'alice'
app_password: '%env(NEXTCLOUD_APP_PASSWORD)%'
rss:
feeds:
- 'https://example.com/feed.xml'
- 'https://another-example.com/rss'
diun:
webhook_token: '%env(DIUN_WEBHOOK_TOKEN)%'
```
Why: this is Symfony's own idiomatic pattern end to end — `useAttributeAsKey()`-backed prototypes for the account list mirror Doctrine's `dbal.connections` and Messenger's `messenger.transports` reference config exactly (§1.3), the vault needs no dependency beyond the `sodium` extension PHP already requires, and `%env(SECRET_NAME)%` is the same placeholder mechanism this project's `.env`-based plan (per `smart-tablet-screen-stack.md`) already relies on for ordinary env vars — so adopting the vault only reclassifies which values are secret, it doesn't introduce a second configuration system.
+77
View File
@@ -0,0 +1,77 @@
{
"version": 1,
"skills": {
"msgraph": {
"source": "merill/msgraph",
"sourceType": "github",
"skillPath": "skills/msgraph/SKILL.md",
"computedHash": "f5a3d61621226c55a9c4e62831da4176e58239c71e9f8067937dba5d6d86c3f7"
},
"symfony:bootstrap-check": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/bootstrap-check/SKILL.md",
"computedHash": "b0770d00e974d3077f0dbab3aaab5b644a67cce72f099a9363fd68a6e7262b43"
},
"symfony:config-env-parameters": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/config-env-parameters/SKILL.md",
"computedHash": "29c30ab73cf591f6940991e1e297836499e1760d390d0fea4faffbf9bc0d42d0"
},
"symfony:controller-cleanup": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/controller-cleanup/SKILL.md",
"computedHash": "e581ac4fa9f7e59035e17a96cdb306ae6fb4c1fe2bb2ddea75fbc8b3c61b7a7f"
},
"symfony:doctrine-migrations": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/doctrine-migrations/SKILL.md",
"computedHash": "f567cc35a98fe8ac6e6632d4da5990f974d4028c9517672586c42a3528861425"
},
"symfony:functional-tests": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/functional-tests/SKILL.md",
"computedHash": "fe487c7a86acc172e4d2d9130d04f7377f3950ce654d095f64a25829b9811435"
},
"symfony:interfaces-and-autowiring": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/interfaces-and-autowiring/SKILL.md",
"computedHash": "18efd79e2b99806aaf7e3311391191e3041fede2e347d474a3c592af5b6e43e7"
},
"symfony:quality-checks": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/quality-checks/SKILL.md",
"computedHash": "603a2a61a4515727f2de2a93d5ef7fcc6bb24da6b3d096f58724b81f18b437a4"
},
"symfony:symfony-cache": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/symfony-cache/SKILL.md",
"computedHash": "92bfade421c2190100ba603b9d0d05d260594be3b4725ba98a9c8963433d7a97"
},
"symfony:tdd-with-phpunit": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/tdd-with-phpunit/SKILL.md",
"computedHash": "484f05f7a16bf1e161eb4d2770b693526eb156f2a7502ea5833f64f5ef83e233"
},
"symfony:test-doubles-mocking": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/test-doubles-mocking/SKILL.md",
"computedHash": "943d6a81a333d1a3a454510cf972c38181428a6d6186d1a19cfbe99ea7ffe3e1"
},
"symfony:using-symfony-superpowers": {
"source": "dev-toolings/superpowers-symfony",
"sourceType": "github",
"skillPath": "skills/using-symfony-superpowers/SKILL.md",
"computedHash": "8666a5b730f5cd8d1b66d07bfdf2afee3bdf0b41acc2a3d699a2bcff55c4151b"
}
}
}