A self-hosted inline CMS for sites you already have.
TweakTags is a self-hosted inline CMS and visual content editor for Next.js, React, and
plain HTML. Bolt it onto a website you already have, instead of rebuilding the site
around a CMS. It lets you change the words and images on your site without leaving the page.
Drop a data-tweaktags-* attribute on any element, and the people you trust
can click in and edit it live. Everyone else just sees the finished content. It installs
from npm and drops into any React or Next app, or a plain HTML site with a single
script tag.
An open source inline CMS and visual content editor, created and maintained by Scarlett A. Scott (@scarlettiron).
Try it without installing anything. The live demo runs the real editor in your browser against an in memory backend. Sign in, change a heading and an image on the page, and save. No sign up, and nothing leaves the tab.
How it works
When a page loads, TweakTags looks for your data-tweaktags- attributes and
fills each one with whatever is saved in your database, so every visitor sees the latest
content. Sign in, flip on edit mode, and those same spots open up for editing, right on
the page, in a popup form, or from a full admin panel, whichever you like. The database
work happens on the server through a small handler and a CLI, and none of it ever reaches
the browser.
A tag can hold plain text, rich text, or media. For a media tag you just
save an image url. TweakTags puts it on the element for you: on an <img>
or <video> it sets the src, and on a container like a
<div> it sets it as a cover background image. No upload pipeline to set
up, just point at a url you already host.
Install
You need Node 16 or newer. If your app uses TypeScript you also need 4.5 or newer, since older versions cannot read the type declarations TweakTags ships. TypeScript is optional, everything works from plain JavaScript too.
One package asks for more: the optional
@tweaktags/auth-aws-cognito needs Node 20 or newer, because
the AWS SDK it depends on does. See
Sign in with AWS Cognito for the full list.
Here is everything a Next.js app on Postgres needs to get going:
# the client bindings, server handler, and Postgres adapter npm install @tweaktags/next # the command line tool, for migrations and users npm install -D @tweaktags/cli
Using a different database? Add its adapter package alongside the above:
npm install @tweaktags/db-mysql # MySQL npm install @tweaktags/db-mariadb # MariaDB npm install @tweaktags/db-sqlite # SQLite
@tweaktags/react and
@tweaktags/server instead of @tweaktags/next, and mount the server
handler on any Node route.
Quick start
1. Configure TweakTags
Create a tweaktags.config.ts in your project root. This one file is the
source of truth for your database and auth. Pull your secrets from the environment so you
never commit them.
import { defineConfig } from '@tweaktags/next'; export default defineConfig({ editInView: true, apiBasePath: '/api/tweaktags', database: { provider: 'postgres', connectionString: process.env.DATABASE_URL, }, auth: { provider: 'jwt', jwtSecret: process.env.TWEAKTAGS_JWT_SECRET, cookieSecure: process.env.NODE_ENV === 'production', }, });
2. Add the API route
Mount the handler once. It reads your config and wires up the database for you.
import { createTweakTagsRouteHandler } from '@tweaktags/next'; import tweaktagsConfig from '../../../tweaktags.config.js'; const { POST } = createTweakTagsRouteHandler(tweaktagsConfig); export const runtime = 'nodejs'; export { POST };
3. Wrap your app
Add the provider and the edit bar to your root layout.
import { TweakTagsProvider, TweakTagsEditBar } from '@tweaktags/next'; export default function RootLayout({ children }) { return ( <html> <body> <TweakTagsProvider apiBasePath="/api/tweaktags"> {children} <TweakTagsEditBar /> </TweakTagsProvider> </body> </html> ); }
4. Set up the database and your first user
npx tweaktags migrate npx tweaktags create-superuser --email you@example.com --password a-strong-password
5. Mark content as editable
Add a data-tweaktags-{tag} attribute to any element. That is it.
<h1 data-tweaktags-hero-title>Welcome to my site</h1> <p data-tweaktags-hero-subtitle>The default text lives right here</p>
Sign in through the edit bar, turn on Edit page, and change the text in place. Reload to confirm it saved.
Three ways to edit
Everyone works differently, so TweakTags gives you three ways to edit. They all talk to the same server and the same data, so you can even mix them.
| Mode | How you turn it on | Best for |
|---|---|---|
| Edit in place | editInView: true (default) |
Editing text right where it appears on the page. |
| Popup form | editInView: false |
Editing many tags at once, or hard to click spots. |
| Admin panel | Render <TweakTagsAdminPanel /> on a route |
A traditional dashboard on its own page. |
The edit bar & toolbar
The TweakTagsEditBar tucks into the corner of the page. Signed out, it is
just a small login card. Once you sign in, it turns into a toolbar for saving, closing,
managing tags, and pulling up help, and you can drag it out of your way by the grip. Edit
a rich text tag and a little formatting toolbar pops up right above it.
The popup editor
Prefer a form? Set editInView: false and Edit page opens a
scrollable popup instead. It lists every tag with a labeled input, already filled in with
what is saved, so you can run down the list, change what you need, and save it all in one
go. Handy when the editable spots are fiddly to click, or when you just want to see
everything in one place.
The popup covers your page, so a tag with nothing saved yet would otherwise be an empty box with only a name on it. Those fields show what the tag currently displays on the page instead, marked Nothing saved yet, so you can read what you are about to replace. Leaving one alone saves nothing: only fields you actually edit are written.
The full page admin panel
If you would rather manage content away from the live site, drop
<TweakTagsAdminPanel /> on its own route and you get a proper dashboard:
a full page login, then tabs to view, create, and edit your tags. The view and edit lists
each come with their own search and pagination, so big sites stay easy to get around.
Both lists show each tag's saved content, so you can read a tag before you open it for editing. Media shows the file itself, rich text renders the way it does on your page, and anything long collapses behind a Show more toggle.
'use client'; import { TweakTagsProvider, TweakTagsAdminPanel } from '@tweaktags/next'; export default function AdminPage() { return ( <TweakTagsProvider apiBasePath="/api/tweaktags" richText> <TweakTagsAdminPanel /> </TweakTagsProvider> ); }
Theming and custom CSS
Out of the box the panel wears a dark look with a vibrant blue accent. Want your own
colors? Pass a theme with any of the tokens below. Need to go further? Pass
styles to restyle individual parts with your own CSS. Skip anything you do
not care about and it keeps our default, so you only write the bits you actually want to
change. There is also a className on the root if you would rather reach for a
stylesheet.
<TweakTagsAdminPanel // recolor everything from a few tokens theme={{ primary: '#16a34a', bg: '#0b0f0c' }} // override just the parts you want, the rest stay default styles={{ card: { borderRadius: '1rem' }, button: { textTransform: 'uppercase' }, }} className="my-admin" />
Theme tokens: bg, surface,
surfaceRaised, border, text, muted,
primary, primaryHover, danger, font.
Style parts: page, loginCard,
shell, topbar, nav, card,
input, button, subtleButton,
dangerButton, label, listRow, badge.
Managing users
Adding an editor or resetting a password used to mean opening a terminal on the server. Now it is a panel. Superusers get Users, everybody signed in gets Account, and both show up in two places: as tabs in the full page admin panel, and as panels in the floating edit bar.
What a superuser can do
- See every user, with their email and role.
- Add a user by email, starting password and role. The password must be at least 8 characters.
- Change somebody's role in either direction. Promoting an editor and demoting another superuser are both allowed.
- Reset somebody's password without knowing their old one.
- Delete a user.
What everybody can do
Editors included, anybody signed in can change their own email and their own password from the Account panel. Both ask for the current password first, so somebody who walks up to an unlocked laptop cannot quietly take the account over.
Four rules the server will not let you break
These are enforced on the server, not just hidden in the interface, so they hold even for somebody calling the API directly.
| Rule | Why |
|---|---|
| You cannot change your own role | Otherwise the last superuser could demote themselves and leave nobody able to manage users, with no way back except editing the database by hand. Promote somebody else and let them change you. |
| You cannot delete yourself | Same reason, more directly. |
| You cannot delete a superuser | Change their role to editor first, then delete them. Deliberate friction: removing an administrator should be two decisions, not one. |
| Changing your own email or password needs your current password | A borrowed session should not be enough to take the account over. |
A demotion bites immediately, not when the access token expires. Every user management action reads the caller's role from the database rather than trusting the role written into their token, so a superuser demoted a second ago has already lost these powers.
Who gets signed out
| What happened | Whose sessions end |
|---|---|
| Their role changed | Theirs, everywhere. The role lives inside the access token. |
| A superuser reset their password | Theirs, everywhere. A reset that left old sessions signed in would not be one. |
| They changed their own password | Their other devices. The one they are using stays signed in. |
| They changed their own email | Nobody. Only the name they sign in with changed. |
| They were deleted | Theirs, everywhere. |
The command line has not gone anywhere. create-user,
create-superuser and update-password still work, and are still
how you make the very first superuser on a fresh install, before there is anybody to
sign in as. TweakTags sends no email, so somebody whose password was reset for them
finds out by being signed out. Tell them yourself.
Authentication
TweakTags has two ways to check a password and you pick one in the config.
provider: 'jwt' keeps the users and the passwords itself.
provider: 'aws-cognito' hands both to an AWS Cognito user pool. It is one line,
and a required one, so the choice is made on purpose rather than defaulted into and
migrated out of later.
provider: 'jwt' |
provider: 'aws-cognito' |
|
|---|---|---|
| What you get |
Built in, nothing to install. Passwords are bcrypt hashes in the
__TweakTags__Users table and TweakTags issues its own access and
refresh tokens. The refresh token rotates on every use, and a reused one is
treated as theft: the whole session family is revoked.
|
An AWS Cognito user pool holds the passwords and issues the tokens, and TweakTags verifies them against the pool's JWKS. Editors sign in with the account they already have, and losing that account takes the editor away with it. |
| What it costs |
A jwtSecret of 16 characters or more, and that is the setup. Access
tokens are stateless, so a revocation only bites once the access token runs out,
unless strictRevocation: true buys you the same second for one
database read per request.
|
@tweaktags/auth-aws-cognito, a pool, an app client that allows
ADMIN_USER_PASSWORD_AUTH, and AWS credentials in the environment.
One database read per request always, no refresh token rotation and so no stolen
token detection, and nobody signs in until their row is linked.
|
| When to pick it | Unless the editors already have accounts somewhere, this one. Nothing else authenticates these people, so there is nothing to defer to. | The editors already live in a pool you run, usually the same one your own application uses. One password each, and disabling it in one place closes the editor too. |
Either way, roles live in the TweakTags users table and the rules under
Managing users hold. The cookie, CSRF and
tokenStorage settings are TweakTags' own as well, so they behave the same
whoever checked the password.
The built-in provider
There is nothing to set up beyond the secret the tokens are signed with:
migrate makes the users table and create-superuser puts the
first person in it. Keep the secret out of the repository, and treat changing it as
signing everybody out.
auth: {
provider: 'jwt',
jwtSecret: process.env.TWEAKTAGS_JWT_SECRET, // 16 characters or more
accessTtlSeconds: 60 * 15, // 15 minutes
refreshTtlSeconds: 60 * 60 * 24 * 7, // 7 days
tokenStorage: 'cookie', // or 'header' for a separate origin app
csrfProtection: true,
strictRevocation: true, // reject access tokens the moment a session is revoked
}
What rotation and revocation actually do, step by step, is under Security.
Sign in with AWS Cognito
Install @tweaktags/auth-aws-cognito and TweakTags checks passwords against
your AWS Cognito user pool instead of its own table.
Versions and requirements
This is the one package in TweakTags that needs Node 20 or newer, and
the floor is the AWS SDK's rather than ours:
@aws-sdk/client-cognito-identity-provider declares
node >= 20.0.0. Everything else in TweakTags still runs on Node 16. The
AWS pieces are ordinary dependencies of the package, so there is nothing extra to
install.
| Thing | Version | Notes |
|---|---|---|
| Node | 20 or newer | For this package only. Node 16 is still the floor everywhere else in TweakTags |
@aws-sdk/client-cognito-identity-provider |
3.600.0 or newer | A normal dependency of the package, installed for you |
aws-jwt-verify |
4.0.1 or newer | Verifies the pool's tokens against its JWKS. Also installed for you |
| AWS Cognito itself | Nothing to match | A managed service, so the thing you pin is the SDK, not the service |
There is no minimum AWS Cognito version, and that is the answer rather
than something left out of the table. Cognito is a managed AWS service: AWS run one
version of it for everybody, so what you pin is the SDK, not the service. The API these
calls speak is the Cognito Identity Provider API, 2016-04-18, which is the
version the SDK client targets.
What the pool has to allow
These have to be true of the pool before the config will work.
| Prerequisite | Why |
|---|---|
The app client allows ADMIN_USER_PASSWORD_AUTH |
The password is checked on your server, not in the browser, so that is the flow TweakTags signs in with. It is off by default on a new app client. |
| AWS credentials come from the environment |
There are no credential fields in the config. The SDK finds them the usual way,
from an instance role, a profile, or environment variables, the same choice the
storage adapter makes. They need AdminInitiateAuth,
AdminGetUser, AdminCreateUser,
AdminSetUserPassword and AdminUserGlobalSignOut.
|
| Nothing in the pool asks the user a question |
TweakTags answers no challenges. A sign-in that comes back with one, MFA or
NEW_PASSWORD_REQUIRED, fails naming it, and that account cannot be
used until it is cleared in Cognito. A pool with MFA enforced cannot be used at
all.
|
| A client secret only if the app client has one |
Set clientSecret and every call carries a SECRET_HASH
built from it, which the adapter does for you. Leave it out otherwise.
|
// npm install @tweaktags/auth-aws-cognito export default defineConfig({ database: { /* … */ }, auth: { provider: 'aws-cognito', awsCognito: { region: 'us-east-1', userPoolId: 'us-east-1_AbCdEfGhI', clientId: process.env.COGNITO_CLIENT_ID, clientSecret: process.env.COGNITO_CLIENT_SECRET, // only if the client has one }, // cookie, csrf and token storage are shared with the jwt provider cookieSecure: true, }, });
There is no jwtSecret here and the type will not let you add one:
auth is a union, so a config names one provider and gets that provider's
fields. Then tell the client which provider the server uses, with
authProvider="aws-cognito" on <TweakTagsProvider> or
TweakTags.init({ authProvider: 'aws-cognito' }). That is presentation only: it
changes what the Add a user form offers, and the server enforces every
rule whatever the client says.
Being in the pool is not access to the editor
A user can only sign in once their TweakTags row is linked to their
AWS Cognito sub. The pool is usually shared with your own application, so
everybody in it having an editor account would be a nasty surprise.
Somebody in the pool but not linked gets exactly the same "The email or password is incorrect" message as somebody typing the wrong password, so the login form cannot be used to work out who has access. It is also what a missed link looks like. Do the linking first and you will never see it.
On a fresh install the command line does both halves at once:
create-superuser makes the account in the pool, or links the one already
there when the address exists, and writes the link on the row it creates. When the
TweakTags row already exists with no link, which is what moving an install from the jwt
provider looks like, link it by hand with link-user. That is the bootstrap
and the only way out of it, since nobody can sign in until they are linked and nobody
can link somebody from the panel until they have signed in.
npx tweaktags migrate
npx tweaktags create-superuser --email you@example.com --password choose-a-strong-password
# or, for a user whose row already exists without a link:
npx tweaktags link-user --email you@example.com --external-id <their aws cognito sub>
The sub is the user's id in the pool, shown on their page in the AWS
console, and --sub is accepted as a spelling of the same flag. The id is
checked against the pool before it is written, so a typo fails there rather than at the
next sign in. An unlinked row cannot have its password changed either, from the panel or
the command line, so linking is the whole of the migration.
Create a new user, or link an existing account
With AWS Cognito configured, the Add a user form in the admin panel and the
edit bar offers Create a new user, which takes an email, a password and
a role, or Link an existing account, which takes an email and a
sub and no password at all. A new account is made with the invitation
suppressed and the password set as permanent, so there is no
FORCE_CHANGE_PASSWORD challenge for the login form to answer.
What changes when you switch
| Thing | Under AWS Cognito |
|---|---|
| Roles |
Still in the TweakTags users table. AWS Cognito groups are never read or written, so
every request costs one database read to resolve the role. Same cost as
strictRevocation: true, and here it is not optional: a Cognito token
carries only its own id.
|
| Deleting a TweakTags user |
Leaves the Cognito account alone, and signs them out of nothing. Losing the CMS
must not delete, or log out, the login your main application depends on. What
shuts them out here is that verify needs a linked row and the row is
gone.
|
| Changing your own email | Changes the TweakTags row only. The link is on the sub, which never changes. |
| Ending sessions | A role change signs nobody out, and needs to sign nobody out: the role is in the TweakTags table and is read on every request, so a demotion bites at once. A password reset does sign them out, pool wide, because their password changed for every application the pool backs. That is all or nothing, so somebody changing their own password is signed out here too and has to sign back in. |
| Refresh tokens | Cognito does not rotate them, so the stolen token reuse detection under Security has no equivalent here. It is a real trade. |
accessTtlSeconds and refreshTtlSeconds |
Stop deciding how long a token lasts, which is the app client's setting now, but still decide how long the cookie holding it is kept. Match them to the app client or a cookie expires while its token is still good. |
auth.strictRevocation |
Does nothing. Cognito already revokes at the source. |
update-password |
Sets the password in Cognito for a linked user, rather than writing a hash into a column nobody reads. |
No framework? No problem.
React is optional. @tweaktags/vanillajs gives you the same thing on any
site: the draggable, mobile friendly edit bar, the popup editor, and the full admin
panel, all in plain JavaScript. The backend is set up exactly the same way, only the
browser side changes.
Drop in a script tag
The quickest way is a CDN script. It is one self contained file that also injects its own styles, so this is the whole browser side, no build step:
<script src="https://cdn.jsdelivr.net/npm/@tweaktags/vanillajs@1.0.0/dist/index.global.js"></script> <script> TweakTags.init({ apiBasePath: '/api/tweaktags' }); </script>
unpkg works too, and its short form resolves to the script build for you:
https://unpkg.com/@tweaktags/vanillajs@1.0.0. The CDN link is automatic once
the package is on npm, nothing to set up. Pin a version like @1.0.0 instead
of the latest so a release cannot change your site by surprise, and put the tag before
</body> or add defer so the page is ready before it runs.
Or install it with npm
// npm install @tweaktags/vanillajs import { init } from '@tweaktags/vanillajs'; init({ apiBasePath: '/api/tweaktags', richText: true, theme: { primary: '#16a34a' } });
Mark content the same way, with data-tweaktags-{tag} attributes. Pass a
theme to recolor everything, or a css string for finer control.
For the full page dashboard on its own route, call
TweakTags.mountAdmin('#admin', { apiBasePath: '/api/tweaktags' }) instead of
init.
Databases
Supported versions
Every version below runs the full adapter test suite in continuous integration on each pull request, against a real server of that version. The tested list and the supported list are deliberately the same list.
| Database | Tested versions | Notes |
|---|---|---|
| Postgres | 13, 14, 15, 16, 17, 18 | 13 is past upstream end of life; take 14 or newer for a new install |
| MySQL | 8.0, 8.4 | 5.7 works, but reached end of life in October 2023 |
| MariaDB | 10.6, 10.11, 11.4, 11.8 | 10.5 works, but is past end of life |
| SQLite | whatever better-sqlite3 bundles |
Not a version you choose |
Postgres comes bundled with the server, so it needs nothing extra. For anything else,
add the matching adapter package and point your config at it. Either way,
migrate and create-superuser behave exactly the same, so you can
switch databases without relearning anything.
| Database | Provider | Extra package |
|---|---|---|
| Postgres | postgres |
None, built in |
| MySQL | mysql |
@tweaktags/db-mysql |
| MariaDB | mariadb |
@tweaktags/db-mariadb |
| SQLite | sqlite |
@tweaktags/db-sqlite |
TweakTags owns a small set of tables that all start with __TweakTags__, so they
never clash with your own tables.
CLI commands
Run these from the root of your app. Each one reads the same tweaktags.config.
| Command | What it does |
|---|---|
tweaktags migrate |
Creates or updates the database tables. |
tweaktags create-superuser --email E --password P |
Creates an admin who can create, retype, and delete tags. |
tweaktags create-user --email E --password P |
Creates a regular editor who can only change existing content. |
tweaktags update-password --email E --password P |
Sets a new password for an existing user. |
tweaktags list-tags |
Lists every tag in the database. |
tweaktags list-users |
Lists every user and their role. |
Multi-tenant: one database, many sites
You can run several sites from a single TweakTags database. Each site is a tenant, and a tag belongs to a tenant, so a site only ever sees and edits its own tags. The best part: the tenant is decided by the server, never by the browser, so a visitor can never pick or fake one.
One config per site
If each site has its own deployment, give each one a tenant:
export default defineConfig({ tenant: 'drystrip', database: { provider: 'postgres', connectionString: process.env.DATABASE_URL }, // shared auth: { provider: 'jwt', jwtSecret: process.env.TWEAKTAGS_JWT_SECRET }, });
Now hero-title on drystrip is a different tag from hero-title on
another site, and an editor signed in on drystrip can only touch drystrip's tags.
One server, many domains
If a single deployment serves several domains, map the request to a tenant instead:
export default defineConfig({ resolveTenant: ({ host }) => (host?.endsWith('drystrip.com') ? 'drystrip' : 'default'), database: { provider: 'postgres', connectionString: process.env.DATABASE_URL }, auth: { provider: 'jwt', jwtSecret: process.env.TWEAKTAGS_JWT_SECRET }, });
default tenant, and the migration backfills old rows for you.
Serverless & connection pools
On a normal Node server there is one process and one connection pool, and there is
nothing to configure. Leave database.pool out and the driver's defaults
apply.
Serverless is different. Every instance that wakes up builds its own pool, so the
connection count multiplies by the number of warm instances:
50 instances × 10 connections = 500 connections. Postgres refuses new
connections past max_connections, often 100 on a small managed instance, so
the app starts failing to connect under exactly the traffic that scaled it up.
export default defineConfig({ database: { provider: 'postgres', connectionString: process.env.DATABASE_URL, pool: { max: 2, // each instance serves one request at a time idleTimeoutMillis: 10_000, // let a sleeping instance let go connectionTimeoutMillis: 5_000, // fail fast instead of hanging }, }, // ...the rest of your config });
| Setting | What it does | Postgres | MySQL / MariaDB |
|---|---|---|---|
max | Most connections one pool opens | yes | yes |
min | Connections kept open when idle | yes | ignored, no minimum exists |
idleTimeoutMillis | How long an idle connection is kept | yes | yes |
connectionTimeoutMillis | How long to wait for a connection | yes | yes |
Every field is optional, and anything left out keeps the driver's own default rather than
being overwritten. SQLite has one file handle and no pool, so it ignores this. Values are
checked when the config is resolved, so a typo fails at startup rather than turning up
later as a connection error. If your host offers a pooler such as PgBouncer, use it and
keep max small as well.
Media uploads
A media tag holds an image url. By default you paste it in. Want to upload files instead? Point TweakTags at an S3 bucket, or any S3 compatible store, and editors get an Upload a file button next to the url box. Either works.
TweakTags never touches the file bytes: the server hands the browser a short lived presigned url, and the file goes straight from the browser to your bucket.
// npm install @tweaktags/storage-s3 export default defineConfig({ database: { /* … */ }, auth: { /* … */ }, storage: { provider: 's3', bucket: process.env.S3_BUCKET, region: process.env.S3_REGION, accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, endpoint: process.env.S3_ENDPOINT, // R2 / Spaces / MinIO publicBaseUrl: process.env.S3_PUBLIC_BASE_URL, // optional cdn }, });
Then turn the button on in the client with mediaUpload
(<TweakTagsProvider … mediaUpload>, or
TweakTags.init({ … mediaUpload: true })). The one S3 adapter covers
S3, Cloudflare R2, DigitalOcean Spaces, Backblaze B2, and MinIO.
PUT from your site's
origin (CORS), since the browser uploads directly, and make the uploaded files publicly
readable (a public prefix or a cdn with publicBaseUrl). Skip storage
entirely and media still works, you just paste urls. Uploads are namespaced per tenant.
White label
TweakTags is white label out of the box. No TweakTags name appears anywhere your editors can see, including the admin panel, so an install looks like part of your own product. There is nothing to turn on.
If you would rather show the TweakTags name, set whiteLabel to
false in two places: your config, and the client.
export default defineConfig({ whiteLabel: false, // ...the rest of your config });
<TweakTagsProvider apiBasePath="/api/tweaktags" whiteLabel={false}>
On a plain HTML site the same switch is
TweakTags.init({ whiteLabel: false }).
Upgrading from 1.1.0 or earlier? White label used to be off by default.
After upgrading, the TweakTags name disappears from your editors' UI unless you set
whiteLabel: false.
Logging & failures
When something on the server goes wrong, TweakTags writes one line about it and hands the
caller a trace id. You do not have to set anything up: with no
logger in your config, entries go to the console your server already has.
[tweaktags] ERROR request.failed trace=mtqj54hvf4av7g action=listTags status=500 code=internal_error durationMs=1
connect ECONNREFUSED 127.0.0.1:5432
{
hint: 'Nothing answered at the database host and port. Check the database is running, and
that the host and port in the config match it. From inside Docker, "localhost"
means the container.',
tenant: 'default',
error: { name: 'Error', message: '...', code: 'ECONNREFUSED', stack: '...' }
}
The trace id is the useful part. The browser gets the same id back, so when someone reports that saving failed, you search your logs for that one string and land on the line that explains it.
Hints
For the failures a self hosted install actually hits, the log says what usually causes them, in plain language:
- A database asked for SSL that it does not support, or one that refuses a connection without SSL
- Nothing listening at the host and port
- A rejected username or password
- A database that is not there, or a SQLite file that cannot be opened
- TweakTags tables that were never migrated
Sending entries somewhere else
Set logger in your config to route entries into whatever you already run.
It is called with one plain object per entry, so it fits any logging library.
export default defineConfig({ logger: (entry) => { // level, event, message, traceId, action, status, durationMs, // tenant, reason, hint, error { name, message, code, stack } myLogger[entry.level](entry.message, entry); }, // ...the rest of your config });
What the browser sees. In production the internal message stays on the server and the response carries only the trace id, so a database error never reaches a visitor. In development the message comes back as well, since whoever sees it is the person who can fix it.
Security
Auth is the part you do not want to get wrong, so TweakTags handles the fiddly bits for you, right out of the box:
- Passwords are hashed with bcrypt before they are stored, when TweakTags is the one storing them. Which provider checks them is a choice you make under Authentication.
- Short lived access tokens and longer refresh tokens, both with configurable expirations. When the refresh token expires, the user is signed out and logs back in.
- Under the jwt provider, refresh tokens rotate on every use, and reuse of an old token revokes the whole session family, which stops stolen tokens.
- Tokens are stored in secure httpOnly cookies by default, with a double submit CSRF check on every action that changes data. You can turn CSRF off with one config flag if it gets in your way.
- Two roles: a superuser manages tags and users, an editor can only change content for tags that already exist. Saved content is checked for SQL injection and dangerous HTML.
- User management reads the caller's role from the database on every call rather than trusting their token, so a demotion takes effect at once.
Deleting somebody, changing their role, or resetting their password all delete their
refresh tokens, so they are signed out for good once their current access token runs
out. Until then, up to accessTtlSeconds, the token they already hold still
works for content edits. Set strictRevocation: true if you need a removal
to take effect the same second.
The full list of auth settings, and the same list for an AWS Cognito pool, is under Authentication.