Skip to content

Latest commit

 

History

History
317 lines (270 loc) · 23 KB

File metadata and controls

317 lines (270 loc) · 23 KB

Regulatory position

Where filahub stands with respect to EU and Swiss regulation, and why. Written for whoever maintains or deploys it. Not legal advice — the reasoning is stated so it can be checked, and so it can be revisited when the facts change.

Companion document: PRIVACY.md covers the operator's data protection duties.

Three roles, kept apart

Role What Regime
Publisher the repository, the container image, the product page licence, CRA, product liability
Operator one running instance GDPR / revFADP, ePrivacy, imprint
Supplier enabling self-hosters to comply no duty as such — but it is the decent thing

Most confusion about "does regulation X apply to filahub" dissolves once the role is named.

Cyber Resilience Act (EU) 2024/2847

Position: out of scope, as long as the project stays non-commercial.

Art. 2 together with recitals 18–19 excludes free and open-source software that is not supplied in the course of a commercial activity. The Commission's guidance of 28 July 2026 states that voluntary donations, public funding and sponsorship alone do not constitute commercial activity, and that paid consulting, training or support do not automatically trigger the CRA either, as long as the software itself remains freely available.

Two things this does not mean:

  • Location is irrelevant. The CRA attaches to products made available on the Union market, not to where the author sits. A maintainer outside the EU gets no exemption from that.
  • The status is not permanent. It flips the moment any of these become true:
    • donations become a de facto condition for access or essential updates
    • there are paid tiers, paid hosting, or premium features
    • personal data is monetised for anything beyond security, compatibility and interoperability

If the status flips, the deadlines are already close: Art. 14 reporting obligations since 11 September 2026 (24 h early warning, 72 h notification, 14 days final report, to the national CSIRT and ENISA), full application including CE marking on 11 December 2027.

Guardrail

Keep donations voluntary and decoupled from access and updates. If that ever changes, this document is the place to record the decision — along with the fact that CRA obligations then apply.

Voluntary alignment

The CRA is not owed here, but several of its expectations are cheap and useful regardless. They also matter for a different reason: a company deploying filahub commercially becomes the manufacturer under the CRA and will need an SBOM from upstream. In place today:

  • SBOM in CycloneDX format, generated per release — attached to the container image as an attestation and kept as a workflow artifact
  • Build provenance via BuildKit and, signed through Sigstore and recorded in a public transparency log, actions/attest-build-provenance. Verifiable with gh attestation verify — see SECURITY.md
  • Dependency updates through Dependabot (npm, GitHub Actions, Docker base image)
  • Vulnerability gate in CI: npm audit over production dependencies, and Trivy against the built image
  • Pinned supply chain: every GitHub Action pinned to a commit SHA, base image pinned to a digest, lockfile committed
  • Least privilege at runtime: the container runs as node, not root, and npm is removed from the finished image — it is only needed to install dependencies, but ships its own dependency tree that would otherwise show up in every scan of a product that never calls it

Open advisories

Production dependencies are currently free of known advisories. What was open here got closed by upgrades, not by exception:

  • The path traversal in @hono/node-server serve-static below 2.0.5 (GHSA-frvp-7c67-39w9, Windows only), by moving to @hono/node-server 2.x — the public API is unchanged, the major bump is a rewrite for throughput. The nested 1.x copy behind @hono/vite-dev-server has since picked up the fix as well.
  • Three moderate advisories in hono below 4.13.5 (GHSA-gqvv-2mrq-wpjv, GHSA-g6gw-c38x-mqfc, GHSA-crvj-82cr-hjcx — toSSG() path traversal, parseBody() memory exhaustion, query parsing past the URL fragment), by moving to hono 4.13.7. None of the three affected code that runs here; the app uses neither toSSG() nor parseBody() and reads no query parameters of its own.

One moderate advisory remains in a development dependency. It does not ship in the image and sits behind an upstream package with no fixed release: drizzle-kit pulls the abandoned @esbuild-kit/esm-loader, and with it esbuild 0.18 (GHSA-67mh-4wv8-2f99). That advisory concerns esbuild's own --serve dev server, which drizzle-kit never starts.

The CI gate stays at npm audit --omit=dev --audit-level=high: production dependencies are the ones that reach users.

What else applies, and what does not

Regime Applies? Why
GDPR to the operator Art. 3(2)(a) once an instance is aimed at people in the EU — being free does not exempt it
revFADP (Switzerland) to Swiss operators Art. 19 information, Art. 25 access, Art. 28 portability, Art. 24 breach notification
ePrivacy Art. 5(3) to the operator the Telegram widget and stored preferences; hence the click-to-load gate
NIS2 no neither sector nor size
Product Liability Directive (EU) 2024/2853 no Art. 2(2) excludes FOSS supplied outside commercial activity; transposition due 9 December 2026
DSA effectively no presupposes services normally provided for remuneration
European Accessibility Act no today microenterprise exemption for services. Switzerland's BehiG revision targets 1 January 2027 and covers commercial services
AI Act no the app calls no model; the import page produces a prompt for the user to use elsewhere

The one to watch is GDPR Art. 27 (representative in the Union) for a non-EU operator running an open instance. The exemption in Art. 27(2) requires processing to be occasional, which sits awkwardly with persistent user accounts. Closing registration is the cheap way out; appointing a representative is the other.

Record of processing activities

Template for Art. 30 GDPR / Art. 12 revFADP. Fill in the operator-specific rows.

  • Controller: from LEGAL_OPERATOR_*
  • Purposes: managing a personal filament stock; authentication; maintaining the shared preset catalogue; sharing stock between users who have connected as friends, and passing on loan requests between them; since 2.5.0, running a stock jointly in an organization (companies, university print hubs, makerspaces); since 2.8.0, keeping the instance usable by limiting how much one account can create and by blocking accounts that abuse it
  • Categories of data subjects: registered users of this instance
  • Categories of data: see the table in PRIVACY.md
  • Recipients: Telegram FZ-LLC (authentication, loan and organization notifications); the hosting provider (processor); other users of this instance — either limited to what the data subject shared with an accepted friend and never including monetary amounts, or, within an organization, its fellow members, who see the shared stock itself and each other's display name and Telegram username (see "Who else gets data" in PRIVACY.md)
  • Legal basis for abuse protection: Art. 6(1)(f) GDPR — the operator's and the other users' interest in an instance that stays usable. The data involved is minimal by construction: counters live in memory only, and the database sees a rejection, never an allowed request. A block additionally records who imposed it, when and for which of five fixed reasons, so that the measure is accountable to the person it hits
  • Legal basis for sharing between users: Art. 6(1)(a) GDPR — the sharing level is a per-friend choice by the data subject, defaults to the narrowest useful setting, and is revocable at any time with immediate effect. The same basis covers organizations: nobody is added without their own act (accepting an invitation, or entering a join code), and leaving takes effect immediately
  • Organization stock is not personal data of its members: rows owned by an organization carry no author column, deliberately. Consequence for Art. 15 and Art. 20: the export contains a member's memberships and invitations, not the organization's inventory — that is the organization's record, not theirs
  • Third-country transfers: Telegram, United Arab Emirates — no adequacy decision; based on explicit consent (Art. 49(1)(a) GDPR / Art. 17(1)(a) revFADP), obtained through the click-to-load gate on the login page
  • Erasure periods: sign-in codes 24 h; security log 90 days; everything else until the account is deleted
  • Technical and organisational measures: see below

Small operators may be exempt from keeping this (Art. 30(5) GDPR, Art. 12(5) revFADP), but the exemptions are narrower than they look — Art. 30(5) requires the processing to be occasional. Regular user accounts are not.

Technical and organisational measures

For Art. 32 GDPR / Art. 8 revFADP. What the software provides:

  • Authentication via Telegram, HMAC-SHA256 verified with a constant-time comparison and a 24-hour replay window (api/telegram/widget.ts)
  • Sessions as HS256 JWTs with the algorithm pinned on verification, 30-day lifetime, revocable through users.tokenVersion
  • Cookies httpOnly, Secure outside localhost, SameSite=Lax
  • Authorisation enforced server-side on every procedure; roles are a Postgres enum, not free text. Client-side gating is cosmetic and documented as such
  • Tenant separation: every user-scoped query takes the user id as a parameter, with explicit ownership checks on top
  • Input validation with shared zod schemas, client and server using the same definitions
  • Rate limiting on every signed-in procedure, keyed on the account, and on sign-in attempts and registration, keyed on the client address; plus fixed upper bounds on how much one account can create (contracts/limits.ts)
  • Blocking of an account, which ends all its sessions in the same statement that sets the block. It never removes the data subject rights: export and erasure run on a separate procedure type (blockedQuery), and a test asserts which procedures that is — a block that swallowed Art. 15 or Art. 17 would be unlawful, and only the blocked person would notice
  • Transport and browser hardening: Content-Security-Policy, X-Frame-Options, Referrer-Policy, Permissions-Policy; HSTS in production
  • Data minimisation: sign-in codes purged after 24 h; no profile pictures; no email addresses
  • Security logging: sign-ins, failed and blocked attempts, rate-limit hits, quotas reached, rejected registrations, blocks and unblock decisions, account exports and deletions, moderation decisions — client addresses stored as an HMAC keyed with APP_SECRET, never in the clear, purged after 90 days. Only rejections are written, never an allowed request, so the log cannot become a usage profile
  • SQL injection: Drizzle ORM throughout; the few raw fragments use only module constants and the identifier escaper

What the operator must add: encrypted transport (HTTPS), backups and their restoration, access control on the host, patching, and a data processing agreement with the hosting provider.

Known gaps, deliberately recorded rather than glossed over:

  • No admin screen for the raw security log. Since 2.8.0 /verwaltung/missbrauch aggregates the rejection events for administrators, but reading individual entries still means querying Postgres directly.
  • No coverage measurement, and no test for the authorisation boundary as a whole — though account deletion, export, session handling, the rate limiter and role assignment are covered.
  • Rate limiting is per process. A deployment with several replicas multiplies the effective limit by the replica count, and each replica sends its own abuse alert.
  • Upper bounds are not enforced by the database. A counter is expressible neither as a unique nor as a partial index, so two simultaneous requests can exceed any of them by one. Recorded rather than glossed over: the bounds are a brake on scripts, not a guarantee.
  • Commits are not consistently signed, and release tags are lightweight rather than signed and annotated.

Erasure concept

Data On account deletion
Own stock: materials, weigh-ins, consumptions, container types, dryboxes, hidden presets deleted
Store shares (lager_shares) — granted and received, before the stores deleted
Stores (lager) — deleted after the materials that point at them deleted
Proposals — pending, rejected, withdrawn deleted
Proposals — accepted anonymised: userId and comment set to NULL
Global catalogue entries kept; they carry no personal data
Moderation record where the deleted user reviewed reviewedBy set to NULL
Sign-in codes deleted
Friendships — in both directions deleted
Loan requests — asked and been asked deleted
Organization memberships and invitations — in both directions deleted
Organizations where the account was the last administrator successor promoted, or the organization and its stock deleted if nobody is left
Stock owned by an organization the account belonged to kept; it carries no author and is not the member's personal data
Friend code deleted with the account row
Security log entries anonymised: actor, subject and Telegram ID set to NULL; event and timestamp kept
Unblock requests the account filed deleted
Blocks the account imposed on others as an administrator kept in force; blockedBy set to NULL
Account deleted

Sign-in codes are additionally purged after 24 hours, and security log entries after 90 days, regardless of any deletion request (api/queries/retention.ts).

Security log entries are anonymised rather than deleted for a specific reason: if deleting an account emptied the log, anyone who gained unauthorised access could erase their own traces by deleting the account they broke into. Art. 17(3) lit. b and e cover keeping the sequence of events; what goes is the link to the person.

Unblock requests are deleted rather than anonymised, unlike accepted proposals: a proposal lives on in a shared catalogue and has to stay traceable, whereas a request concerns only the relationship between one person and this instance and is moot once the account is gone. The block on the deleted account goes with it, which is no loophole — the account's entire stock goes too, and what prevents an immediate return is the registration limit rather than a leftover row.

The reasoning for keeping accepted proposals is in deleteUserAccount and in the privacy policy shown to users. It rests on Art. 17(3): other users' stock references those catalogue entries, and the remaining row no longer identifies anyone.

Friendships, store shares and loan requests get the opposite treatment — deleted outright, in both directions. Nothing in Art. 17(3) covers keeping them: there is no moderation record, no other user's stock depends on them, and an anonymised friendship would be meaningless. Deleting them does remove the counterparty's side of a row that describes both people; that is inherent to joint data, and the erasure right of the person leaving takes precedence over the other's convenience.

Organizations get a third treatment, because neither of the first two fits. The memberships and invitations go the way the friendships do — joint data, no moderation purpose, deleted in both directions. The stock stays, and that is a consequence of a design decision rather than an exception carved out for it: rows owned by an organization have no author column, so they contain nothing to erase. Recording who booked what in would have been a usage log, which this project declines to keep for the same reason it does not log loan requests.

The last-administrator case is the one place where erasure forces a decision instead of a rule. Everywhere else the app refuses a step that would leave an organization without an administrator, since nobody could then repair it. An Art. 17 request is not refusable, so handleAdminAccountDeletion decides: the longest-standing remaining member is promoted (logged as organization.member_role_changed with reason: "last_admin_deleted", and told over Telegram), or, if nobody remains, the organization and its entire stock are deleted — data no one can ever reach again is worse than deleting it.

The order in the table is load-bearing, not tidiness. There are no foreign keys anywhere in this schema, so a row deleted too late is a row pointing at an ID the database will hand out again: a leftover lager_shares row would grant a stranger access to whoever next receives that store ID. The same reasoning puts the stores after the materials that reference them, puts the organization cascade before the personal one, and all of it is enforced by the order of statements in deleteUserAccount with the reasoning in the comments there.

Decisions on record

Add entries here as they are made, with dates. This section is the point of the document — a position that is not written down gets re-litigated.

Date Decision Rationale
to be filled Registration open or restricted on the public instance affects Art. 27 and Art. 30
to be filled GDPR Art. 27 representative appointed / reasoned exemption see above
to be filled Donation model confirmed non-commercial keeps the CRA exemption
2026-08-11 Sharing between users is per-direction, never symmetric a symmetric level would let one user widen what another discloses; each person must control their own stock alone
2026-08-11 Access exports include the counterparty's display name the mirror image of stripping ipHash: the name is what makes the row meaningful to the data subject, who already sees it in the interface, and IDs alone would be a useless answer under Art. 15
2026-08-11 "Search only" is documented as a courtesy, not a guarantee server-side search with a mandatory query prevents browsing, but repeated queries can still map a stock; users who need a hard boundary must pick "nothing"
2026-08-11 Loan requests are not written to the security log they are usage data; logging them would create the movement profile contracts/audit.ts explicitly excludes