Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ follows [Semantic Versioning](https://semver.org).

## [Unreleased]

### Changed

- **BREAKING: no default admin is created any more.** `ensureDefaultAdmin()`
and the plaintext `initial-password.txt` are gone (that file was committed to a
repo once). The first admin is an explicit act: `dune users:create <email>
--role admin --password-prompt`, or `DUNE_ADMIN_EMAIL` + `DUNE_ADMIN_PASSWORD`
at boot (handled by `@dune/core`). A site with no enabled admin logs a warning
saying how to create one. Existing sites that already have an admin are
unaffected. `UserManager.ensureDefaultAdmin` and `generatePassword` are removed.
- **Password hashing comes from `@dune/core/auth/passwords`.** The plugin's own
copy (`src/admin/auth/passwords.ts`) is removed; `hashPassword`,
`verifyPassword` and `DUMMY_HASH` are still re-exported from the admin entry
point. The hash format is unchanged. **Requires `@dune/core` 0.35.3 or newer**
(it adds `@dune/core/auth/passwords` and the env bootstrap, duneorg/dune#84
and #86).

## [3.2.0] — 2026-11-03

### Changed
Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"./admin/auth/provider": "./src/admin/auth/provider.ts"
},
"imports": {
"@dune/core": "jsr:@dune/core@^0.35.2",
"@dune/core": "jsr:@dune/core@^0.35.3",
"fresh": "jsr:@fresh/core@^2",
"preact": "npm:preact@^10",
"preact/hooks": "npm:preact@^10/hooks",
Expand Down
22 changes: 14 additions & 8 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export function createAdminPlugin(
logger.warn("admin.users.legacy-location", {
legacyDir: legacyUsersDir,
newDir: `${dataDir}/users`,
message: "Move user files or a new default admin will be created",
message: "Move the user files to the new directory, or those accounts will not be found",
});
}
} catch { /* ignore */ }
Expand Down Expand Up @@ -382,16 +382,22 @@ export function createAdminPlugin(
// AdminContext type — plugin-admin owns the actual shape.
bootstrap.adminContext = adminContext as unknown as Record<string, unknown>;

// Ensure a default admin user exists on first run.
const result = await users.ensureDefaultAdmin();
if (result.created) {
console.log(`\n 🔑 Default admin created — username: admin`);
console.log(` Password written to: ${result.passwordFile}`);
console.log(` Read it, then delete the file and change your password.\n`);
// No default admin is ever created. The first admin is an explicit act:
// `dune users:create <email> --role admin --password-prompt`, or
// DUNE_ADMIN_EMAIL / DUNE_ADMIN_PASSWORD at boot (handled by core before
// this runs). Say so when there is none, instead of leaving a login page
// nobody can pass.
const existingUsers = await users.list();
if (!existingUsers.some((u) => u.enabled !== false && u.roles.includes("admin"))) {
console.warn(
`\n ⚠️ No admin account yet — the admin panel has nobody who can sign in.\n` +
` Create one: dune users:create <email> --role admin --password-prompt\n` +
` (or set DUNE_ADMIN_EMAIL and DUNE_ADMIN_PASSWORD for the first boot)\n`,
);
}

// ── authz tuple bootstrap ─────────────────────────────────────────────────
// Runs after ensureDefaultAdmin() so a freshly-created default admin's
// Runs after core's env bootstrap so a freshly-created first admin's
// tuple gets registered too, instead of being locked out on first run.
if (bootstrap.authz && bootstrap.authzAdapter) {
try {
Expand Down
12 changes: 7 additions & 5 deletions scripts/generate-core-imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,20 @@
* auto-tracks patch releases within that minor — confirmed via `deno cache`:
* "0.28" resolved to the latest 0.28.x, not just 0.28.0.
*
* Floored at 0.35.2: the public form routes and the submission store moved into
* core there (`BootstrapResult.submissions`, `registerFormRoutes()` and the
* `onSystemAction` hook from `@dune/core/forms`), and this package no longer
* registers them itself. A bare "0.35" would floor JSR's publish-time check at
* Floored at 0.35.3: password hashing (`@dune/core/auth/passwords`) and the
* `DUNE_ADMIN_*` first-admin bootstrap moved into core there, and this package
* no longer carries its own copy or a default admin. (0.35.2 before that: the
* public form routes and the submission store moved into core, with
* `BootstrapResult.submissions`, `registerFormRoutes()` and the `onSystemAction`
* hook from `@dune/core/forms`.) A bare "0.35" would floor JSR's publish-time check at
* 0.35.0 (reproduced 2026-08-31 with "0.34":
* "invalid 'jsr:' dependency subpath: '@dune/core@0.34/auth/authz-schema',
* resolved to 0.34.0, has no export") and let a site pair this package with a
* core that cannot serve those routes. Back to a bare minor at the next core
* minor. This constant and deno.json are only kept in step by
* `check:core-imports`, so run it before merging any pin change.
*/
const CORE_RANGE = "^0.35.2";
const CORE_RANGE = "^0.35.3";

const DENO_JSON = "deno.json";
const SCAN_ROOTS = ["mod.ts", "src", "tests"];
Expand Down
2 changes: 1 addition & 1 deletion src/admin/auth/local-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import type { AuthProvider, AuthCredentials, AuthProviderUser } from "./provider.ts";
import type { UserManager } from "./users.ts";
import { verifyPassword, DUMMY_HASH } from "./passwords.ts";
import { verifyPassword, DUMMY_HASH } from "@dune/core/auth/passwords";

export class LocalAuthProvider implements AuthProvider {
readonly type = "local" as const;
Expand Down
123 changes: 0 additions & 123 deletions src/admin/auth/passwords.ts

This file was deleted.

71 changes: 6 additions & 65 deletions src/admin/auth/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@
* (Phase 0) admin accounts never had before this. Adds admin-specific
* conveniences the generic UserStore has no reason to know about: a single
* `role: Role` create/update parameter (translated to/from `roles: string[]`
* via role-utils.ts), password hashing, and `ensureDefaultAdmin()`.
* via role-utils.ts) and password hashing (via @dune/core/auth/passwords).
*
* There is deliberately no default admin: the first admin is created
* explicitly with `dune users:create --role admin --password-prompt` or from
* DUNE_ADMIN_EMAIL / DUNE_ADMIN_PASSWORD at boot (both in @dune/core).
*/

import type { StorageAdapter } from "@dune/core/storage";
import { createLocalUserStore } from "@dune/core/auth/user-store";
import type { UserStore } from "@dune/core/auth/user-store";
import type { User, Role } from "../types.ts";
import { hashPassword } from "./passwords.ts";
import { hashPassword } from "@dune/core/auth/passwords";
import { withRole } from "./role-utils.ts";

/** Options for {@link createUserManager}. */
Expand Down Expand Up @@ -53,13 +57,6 @@ export interface UserManager {
changePassword(id: string, newPassword: string): Promise<boolean>;
/** Delete a user */
delete(id: string): Promise<boolean>;
/**
* Ensure a default admin user exists (for first-time setup).
* If created, the password is written to a file on disk (not returned in
* the response) to avoid it appearing in logs or process output.
* `passwordFile` is the path to that file when `created` is true.
*/
ensureDefaultAdmin(): Promise<{ created: boolean; passwordFile?: string }>;
}

/**
Expand Down Expand Up @@ -102,41 +99,6 @@ export function createUserManager(config: UserManagerConfig): UserManager {
return updated !== null;
}

async function ensureDefaultAdmin(): Promise<{ created: boolean; passwordFile?: string }> {
const allUsers = await store.list();
const admins = allUsers.filter((u) => u.enabled && u.roles.includes("admin"));

if (admins.length > 0) {
if (admins.length > 1) {
console.warn(`[dune/users] ⚠️ Found ${admins.length} enabled admin users (${admins.map((u) => u.username).join(", ")})`);
}
return { created: false };
}

if (allUsers.length > 0) {
console.warn(`[dune/users] ⚠️ Found ${allUsers.length} user(s) but none are enabled admins — creating default admin`);
}

// Generate a random password and write it to a file — never log it.
const password = generatePassword();

await create({
username: "admin",
email: "admin@localhost",
password,
role: "admin",
name: "Admin",
});

// Write the password to a file next to the users directory.
// The caller can print the file path; the password never appears in logs.
const passwordFile = `${usersDir}/../initial-password.txt`;
const content = `Dune CMS — initial admin password\n\nUsername: admin\nPassword: ${password}\n\nDelete this file after logging in and changing your password.\n`;
await storage.write(passwordFile, new TextEncoder().encode(content));

return { created: true, passwordFile };
}

return {
create,
getById: store.getById,
Expand All @@ -145,26 +107,5 @@ export function createUserManager(config: UserManagerConfig): UserManager {
update,
changePassword,
delete: store.delete,
ensureDefaultAdmin,
};
}

/**
* Generate a random password from an unambiguous alphabet. Uses rejection
* sampling so every character is equally likely — `byte % chars.length` would
* favor the first (256 % chars.length) characters.
*/
export function generatePassword(length = 16): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
const limit = 256 - (256 % chars.length);
let out = "";
while (out.length < length) {
const bytes = crypto.getRandomValues(new Uint8Array(length * 2));
for (const b of bytes) {
if (b >= limit) continue;
out += chars[b % chars.length];
if (out.length === length) break;
}
}
return out;
}
2 changes: 1 addition & 1 deletion src/admin/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type { UserManager, UserManagerConfig, CreateUserInput } from "./auth/use
export { createAuthMiddleware } from "./auth/middleware.ts";
export type { AuthMiddleware, AuthMiddlewareConfig } from "./auth/middleware.ts";

export { hashPassword, verifyPassword, DUMMY_HASH } from "./auth/passwords.ts";
export { hashPassword, verifyPassword, DUMMY_HASH } from "@dune/core/auth/passwords";

export type {
AuthProvider,
Expand Down
2 changes: 1 addition & 1 deletion src/admin/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import type { FreshContext } from "fresh";
import type { AdminState } from "../types.ts";
import { verifyPassword, DUMMY_HASH, needsRehash } from "../auth/passwords.ts";
import { verifyPassword, DUMMY_HASH, needsRehash } from "@dune/core/auth/passwords";
import { findOrProvisionUser } from "../auth/provisioner.ts";
import { RateLimiter, clientIp } from "@dune/core/security";
import { actorFromAuth, csrfCheck } from "./api/_utils.ts";
Expand Down
2 changes: 1 addition & 1 deletion src/admin/timing-safe.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Constant-time byte-array comparison.
*
* Single implementation shared by password verification (auth/passwords.ts),
* Single implementation shared by password verification (@dune/core/auth/passwords),
* CSRF token matching (auth/csrf.ts), and incoming-webhook token checks
* (public-api.ts) — three copies of security-critical code were a drift risk
* (Q-2, Aug 2026 quality audit).
Expand Down
Loading
Loading