Compare commits
4 Commits
8f3a1f2962
...
730021a5ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
730021a5ea
|
|||
|
c698a85cc1
|
|||
|
462799ca3d
|
|||
|
dcb48a5d5e
|
24
README.md
24
README.md
@@ -19,6 +19,11 @@ A modern publisher dashboard for managing organizations, members, and sites. Bui
|
|||||||
- **PostgreSQL** database
|
- **PostgreSQL** database
|
||||||
- **Postmark** for transactional emails
|
- **Postmark** for transactional emails
|
||||||
|
|
||||||
|
### CLI (`apps/cli`)
|
||||||
|
- **Stricli** for command parsing
|
||||||
|
- API token-based authentication
|
||||||
|
- User, organization, and site management commands
|
||||||
|
|
||||||
### Shared Packages
|
### Shared Packages
|
||||||
- `@reviq/api-contract` - Shared API contract (oRPC)
|
- `@reviq/api-contract` - Shared API contract (oRPC)
|
||||||
- `@reviq/db` - Database client and queries
|
- `@reviq/db` - Database client and queries
|
||||||
@@ -31,7 +36,7 @@ A modern publisher dashboard for managing organizations, members, and sites. Bui
|
|||||||
publisher-dashboard/
|
publisher-dashboard/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── api-server/ # Backend API server
|
│ ├── api-server/ # Backend API server
|
||||||
│ ├── cli/ # CLI tools
|
│ ├── cli/ # Command-line interface
|
||||||
│ └── publisher-dashboard/ # SvelteKit frontend
|
│ └── publisher-dashboard/ # SvelteKit frontend
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── api-contract/ # Shared oRPC contract
|
│ ├── api-contract/ # Shared oRPC contract
|
||||||
@@ -107,6 +112,23 @@ bun run dev
|
|||||||
| `bun run test` | Run tests |
|
| `bun run test` | Run tests |
|
||||||
| `bun run db:codegen` | Generate database types |
|
| `bun run db:codegen` | Generate database types |
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
The `@reviq/cli` package provides a command-line interface for managing users, organizations, and sites. See [apps/cli/README.md](apps/cli/README.md) for detailed usage.
|
||||||
|
|
||||||
|
Quick start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the CLI
|
||||||
|
bun run --cwd apps/cli build
|
||||||
|
|
||||||
|
# Login with an API token
|
||||||
|
./apps/cli/dist/reviq auth login --token <your-token>
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
./apps/cli/dist/reviq auth status
|
||||||
|
```
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -19,39 +19,30 @@ import { hashToken } from "../../utils/crypto.js";
|
|||||||
import { getUserPasskeys } from "../../utils/webauthn.js";
|
import { getUserPasskeys } from "../../utils/webauthn.js";
|
||||||
import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js";
|
import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js";
|
||||||
import {
|
import {
|
||||||
createTestDb,
|
|
||||||
createTestUser,
|
createTestUser,
|
||||||
destroyTestDb,
|
destroySharedDb,
|
||||||
runMigrations,
|
getSharedDb,
|
||||||
truncateAllTables,
|
initTestDb,
|
||||||
} from "../helpers/test-db.js";
|
} from "../helpers/test-db.js";
|
||||||
|
import { withTestTransaction } from "../helpers/test-transaction.js";
|
||||||
|
|
||||||
/** Session expiry duration: 24 hours in milliseconds */
|
/** Session expiry duration: 24 hours in milliseconds */
|
||||||
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
let db: Kysely<Database> | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the database connection, throwing if not initialized
|
|
||||||
*/
|
|
||||||
function getDb(): Kysely<Database> {
|
|
||||||
if (!db) {
|
|
||||||
throw new Error("Database not initialized");
|
|
||||||
}
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an API context with optional session token
|
* Create an API context with optional session token
|
||||||
*/
|
*/
|
||||||
function createAPIContext(sessionToken?: string): APIContext {
|
function createAPIContext(
|
||||||
|
db: Kysely<Database>,
|
||||||
|
sessionToken?: string,
|
||||||
|
): APIContext {
|
||||||
const reqHeaders = new Headers();
|
const reqHeaders = new Headers();
|
||||||
if (sessionToken) {
|
if (sessionToken) {
|
||||||
reqHeaders.set("cookie", `${COOKIE_NAMES.SESSION_TOKEN}=${sessionToken}`);
|
reqHeaders.set("cookie", `${COOKIE_NAMES.SESSION_TOKEN}=${sessionToken}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db: getDb(),
|
db,
|
||||||
origin: TEST_RP.origin,
|
origin: TEST_RP.origin,
|
||||||
allowedOrigins: [...TEST_RP.allowedOrigins],
|
allowedOrigins: [...TEST_RP.allowedOrigins],
|
||||||
rpName: TEST_RP.rpName,
|
rpName: TEST_RP.rpName,
|
||||||
@@ -63,12 +54,15 @@ function createAPIContext(sessionToken?: string): APIContext {
|
|||||||
/**
|
/**
|
||||||
* Create a real session in the database and return the token
|
* Create a real session in the database and return the token
|
||||||
*/
|
*/
|
||||||
async function createSession(userId: number): Promise<string> {
|
async function createSession(
|
||||||
|
db: Kysely<Database>,
|
||||||
|
userId: number,
|
||||||
|
): Promise<string> {
|
||||||
const token = `test-session-${String(Date.now())}${String(Math.random())}`;
|
const token = `test-session-${String(Date.now())}${String(Math.random())}`;
|
||||||
const tokenHashValue = await hashToken(token);
|
const tokenHashValue = await hashToken(token);
|
||||||
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
|
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
|
||||||
|
|
||||||
await getDb()
|
await db
|
||||||
.insertInto("sessions")
|
.insertInto("sessions")
|
||||||
.values({
|
.values({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
@@ -87,13 +81,14 @@ async function createSession(userId: number): Promise<string> {
|
|||||||
* Create a login request in the database and return ID and token
|
* Create a login request in the database and return ID and token
|
||||||
*/
|
*/
|
||||||
async function createLoginRequest(
|
async function createLoginRequest(
|
||||||
|
db: Kysely<Database>,
|
||||||
userId: number,
|
userId: number,
|
||||||
email: string,
|
email: string,
|
||||||
): Promise<{ id: number; token: string }> {
|
): Promise<{ id: number; token: string }> {
|
||||||
const token = `test-login-${String(Date.now())}${String(Math.random())}`;
|
const token = `test-login-${String(Date.now())}${String(Math.random())}`;
|
||||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
||||||
|
|
||||||
const result = await getDb()
|
const result = await db
|
||||||
.insertInto("login_requests")
|
.insertInto("login_requests")
|
||||||
.values({
|
.values({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
@@ -110,20 +105,26 @@ async function createLoginRequest(
|
|||||||
/**
|
/**
|
||||||
* Create an authenticated API context for a user (creates session + context)
|
* Create an authenticated API context for a user (creates session + context)
|
||||||
*/
|
*/
|
||||||
async function createUserAPIContext(userId: number): Promise<APIContext> {
|
async function createUserAPIContext(
|
||||||
const sessionToken = await createSession(userId);
|
db: Kysely<Database>,
|
||||||
return createAPIContext(sessionToken);
|
userId: number,
|
||||||
|
): Promise<APIContext> {
|
||||||
|
const sessionToken = await createSession(db, userId);
|
||||||
|
return createAPIContext(db, sessionToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an API context with login request cookie
|
* Create an API context with login request cookie
|
||||||
*/
|
*/
|
||||||
function createLoginRequestContext(loginToken: string): APIContext {
|
function createLoginRequestContext(
|
||||||
|
db: Kysely<Database>,
|
||||||
|
loginToken: string,
|
||||||
|
): APIContext {
|
||||||
const reqHeaders = new Headers();
|
const reqHeaders = new Headers();
|
||||||
reqHeaders.set("cookie", `${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${loginToken}`);
|
reqHeaders.set("cookie", `${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${loginToken}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db: getDb(),
|
db,
|
||||||
origin: TEST_RP.origin,
|
origin: TEST_RP.origin,
|
||||||
allowedOrigins: [...TEST_RP.allowedOrigins],
|
allowedOrigins: [...TEST_RP.allowedOrigins],
|
||||||
rpName: TEST_RP.rpName,
|
rpName: TEST_RP.rpName,
|
||||||
@@ -149,12 +150,13 @@ function expectFirst<T>(arr: T[], message: string): T {
|
|||||||
* Shared helper to avoid duplication across test suites.
|
* Shared helper to avoid duplication across test suites.
|
||||||
*/
|
*/
|
||||||
async function registerPasskey(
|
async function registerPasskey(
|
||||||
|
db: Kysely<Database>,
|
||||||
userId: number,
|
userId: number,
|
||||||
email: string,
|
email: string,
|
||||||
authenticator: VirtualAuthenticator,
|
authenticator: VirtualAuthenticator,
|
||||||
) {
|
) {
|
||||||
const apiCtx = createAPIContext();
|
const apiCtx = createAPIContext(db);
|
||||||
const authCtx = await createUserAPIContext(userId);
|
const authCtx = await createUserAPIContext(db, userId);
|
||||||
|
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createRegistrationOptions,
|
router.auth.webauthn.createRegistrationOptions,
|
||||||
@@ -175,12 +177,13 @@ async function registerPasskey(
|
|||||||
* Shared helper to avoid duplication across test suites.
|
* Shared helper to avoid duplication across test suites.
|
||||||
*/
|
*/
|
||||||
async function authenticate(
|
async function authenticate(
|
||||||
|
db: Kysely<Database>,
|
||||||
userId: number,
|
userId: number,
|
||||||
email: string,
|
email: string,
|
||||||
authenticator: VirtualAuthenticator,
|
authenticator: VirtualAuthenticator,
|
||||||
) {
|
) {
|
||||||
const { token: loginToken } = await createLoginRequest(userId, email);
|
const { token: loginToken } = await createLoginRequest(db, userId, email);
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
|
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
@@ -196,27 +199,20 @@ async function authenticate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Run migrations and create test database connection
|
await initTestDb();
|
||||||
await runMigrations();
|
|
||||||
db = createTestDb();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (db) {
|
await destroySharedDb();
|
||||||
await destroyTestDb(db);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("registration flow", () => {
|
describe("registration flow", () => {
|
||||||
beforeAll(async () => {
|
|
||||||
await truncateAllTables(getDb());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates registration options with challenge stored in DB via router", async () => {
|
test("creates registration options with challenge stored in DB via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "reg-options@test.com",
|
email: "reg-options@test.com",
|
||||||
});
|
});
|
||||||
const ctx = createAPIContext();
|
const ctx = createAPIContext(db);
|
||||||
|
|
||||||
// Call router handler directly
|
// Call router handler directly
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
@@ -235,16 +231,18 @@ describe("registration flow", () => {
|
|||||||
|
|
||||||
// Verify challenge is stored in database
|
// Verify challenge is stored in database
|
||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
?.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("id")
|
.select("id")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", String(challengeId))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
expect(challengeRow).toBeDefined();
|
expect(challengeRow).toBeDefined();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("verifies valid registration and stores passkey via router", async () => {
|
test("verifies valid registration and stores passkey via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "reg-verify@test.com",
|
email: "reg-verify@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({
|
const authenticator = new VirtualAuthenticator({
|
||||||
@@ -252,7 +250,7 @@ describe("registration flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create registration options via router
|
// Create registration options via router
|
||||||
const apiCtx = createAPIContext();
|
const apiCtx = createAPIContext(db);
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createRegistrationOptions,
|
router.auth.webauthn.createRegistrationOptions,
|
||||||
{ email: user.email },
|
{ email: user.email },
|
||||||
@@ -263,7 +261,7 @@ describe("registration flow", () => {
|
|||||||
const response = authenticator.createCredential(options);
|
const response = authenticator.createCredential(options);
|
||||||
|
|
||||||
// Verify registration via router (requires authenticated session)
|
// Verify registration via router (requires authenticated session)
|
||||||
const authCtx = await createUserAPIContext(user.id);
|
const authCtx = await createUserAPIContext(db, user.id);
|
||||||
await call(
|
await call(
|
||||||
router.auth.webauthn.verifyRegistration,
|
router.auth.webauthn.verifyRegistration,
|
||||||
{ challengeId, response },
|
{ challengeId, response },
|
||||||
@@ -271,20 +269,24 @@ describe("registration flow", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Verify passkey is stored in database
|
// Verify passkey is stored in database
|
||||||
const passkeys = await getUserPasskeys(getDb(), user.id);
|
const passkeys = await getUserPasskeys(db, user.id);
|
||||||
expect(passkeys).toHaveLength(1);
|
expect(passkeys).toHaveLength(1);
|
||||||
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.rpid).toBe(TEST_RP.rpID);
|
expect(firstPasskey.rpid).toBe(TEST_RP.rpID);
|
||||||
expect(firstPasskey.counter).toBe(0);
|
expect(firstPasskey.counter).toBe(0);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("excludes existing passkeys for returning users via router", async () => {
|
test("excludes existing passkeys for returning users via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "exclude-test@test.com",
|
email: "exclude-test@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
const apiCtx = createAPIContext();
|
origin: TEST_RP.origin,
|
||||||
const authCtx = await createUserAPIContext(user.id);
|
});
|
||||||
|
const apiCtx = createAPIContext(db);
|
||||||
|
const authCtx = await createUserAPIContext(db, user.id);
|
||||||
|
|
||||||
// Register first passkey via router
|
// Register first passkey via router
|
||||||
const { options: options1, challengeId: challengeId1 } = await call(
|
const { options: options1, challengeId: challengeId1 } = await call(
|
||||||
@@ -314,9 +316,11 @@ describe("registration flow", () => {
|
|||||||
);
|
);
|
||||||
expect(excludedCred.id).toBe(response1.id);
|
expect(excludedCred.id).toBe(response1.id);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("assigns friendly name from known AAGUID via router", async () => {
|
test("assigns friendly name from known AAGUID via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "aaguid-test@test.com",
|
email: "aaguid-test@test.com",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -326,8 +330,8 @@ describe("registration flow", () => {
|
|||||||
aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN,
|
aaguid: KNOWN_AAGUIDS.ICLOUD_KEYCHAIN,
|
||||||
});
|
});
|
||||||
|
|
||||||
const apiCtx = createAPIContext();
|
const apiCtx = createAPIContext(db);
|
||||||
const authCtx = await createUserAPIContext(user.id);
|
const authCtx = await createUserAPIContext(db, user.id);
|
||||||
|
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createRegistrationOptions,
|
router.auth.webauthn.createRegistrationOptions,
|
||||||
@@ -341,19 +345,23 @@ describe("registration flow", () => {
|
|||||||
{ context: authCtx },
|
{ context: authCtx },
|
||||||
);
|
);
|
||||||
|
|
||||||
const passkeys = await getUserPasskeys(getDb(), user.id);
|
const passkeys = await getUserPasskeys(db, user.id);
|
||||||
expect(passkeys).toHaveLength(1);
|
expect(passkeys).toHaveLength(1);
|
||||||
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.name).toBe("iCloud Keychain");
|
expect(firstPasskey.name).toBe("iCloud Keychain");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("cleans up challenge after verification via router", async () => {
|
test("cleans up challenge after verification via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "cleanup-test@test.com",
|
email: "cleanup-test@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
const apiCtx = createAPIContext();
|
origin: TEST_RP.origin,
|
||||||
const authCtx = await createUserAPIContext(user.id);
|
});
|
||||||
|
const apiCtx = createAPIContext(db);
|
||||||
|
const authCtx = await createUserAPIContext(db, user.id);
|
||||||
|
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createRegistrationOptions,
|
router.auth.webauthn.createRegistrationOptions,
|
||||||
@@ -369,21 +377,25 @@ describe("registration flow", () => {
|
|||||||
|
|
||||||
// Challenge should be deleted
|
// Challenge should be deleted
|
||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
?.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("id")
|
.select("id")
|
||||||
.where("id", "=", String(challengeId))
|
.where("id", "=", String(challengeId))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
expect(challengeRow).toBeUndefined();
|
expect(challengeRow).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects expired/missing challenges via router", async () => {
|
test("rejects expired/missing challenges via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "expired-test@test.com",
|
email: "expired-test@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
const apiCtx = createAPIContext();
|
origin: TEST_RP.origin,
|
||||||
const authCtx = await createUserAPIContext(user.id);
|
});
|
||||||
|
const apiCtx = createAPIContext(db);
|
||||||
|
const authCtx = await createUserAPIContext(db, user.id);
|
||||||
|
|
||||||
// Create options via router
|
// Create options via router
|
||||||
const { options } = await call(
|
const { options } = await call(
|
||||||
@@ -406,29 +418,34 @@ describe("registration flow", () => {
|
|||||||
expect((error as Error).message).toContain("Registration timed out");
|
expect((error as Error).message).toContain("Registration timed out");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("authentication flow", () => {
|
describe("authentication flow", () => {
|
||||||
beforeAll(async () => {
|
|
||||||
await truncateAllTables(getDb());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates authentication options with user's passkeys via router", async () => {
|
test("creates authentication options with user's passkeys via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "auth-options@test.com",
|
email: "auth-options@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register a passkey first via router
|
// Register a passkey first via router
|
||||||
const regResponse = await registerPasskey(
|
const regResponse = await registerPasskey(
|
||||||
|
db,
|
||||||
user.id,
|
user.id,
|
||||||
user.email,
|
user.email,
|
||||||
authenticator,
|
authenticator,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create authentication options via router
|
// Create authentication options via router
|
||||||
const { token: loginToken } = await createLoginRequest(user.id, user.email);
|
const { token: loginToken } = await createLoginRequest(
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -445,19 +462,27 @@ describe("authentication flow", () => {
|
|||||||
expect(allowedCred.id).toBe(regResponse.id);
|
expect(allowedCred.id).toBe(regResponse.id);
|
||||||
expect(challengeId).toBeGreaterThan(0);
|
expect(challengeId).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("verifies valid authentication and updates counter via router", async () => {
|
test("verifies valid authentication and updates counter via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "auth-verify@test.com",
|
email: "auth-verify@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register passkey via router
|
// Register passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Authenticate via router
|
// Authenticate via router
|
||||||
const { token: loginToken } = await createLoginRequest(user.id, user.email);
|
const { token: loginToken } = await createLoginRequest(
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options: authOptions, challengeId: authChallengeId } = await call(
|
const { options: authOptions, challengeId: authChallengeId } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -472,26 +497,34 @@ describe("authentication flow", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Verify counter was updated
|
// Verify counter was updated
|
||||||
const passkeys = await getUserPasskeys(getDb(), user.id);
|
const passkeys = await getUserPasskeys(db, user.id);
|
||||||
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.counter).toBe(1);
|
expect(firstPasskey.counter).toBe(1);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("updates last_used_at timestamp via router", async () => {
|
test("updates last_used_at timestamp via router", async () => {
|
||||||
const user = await createTestUser(getDb(), { email: "last-used@test.com" });
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const user = await createTestUser(db, { email: "last-used@test.com" });
|
||||||
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register passkey via router
|
// Register passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Check initial state
|
// Check initial state
|
||||||
let passkeys = await getUserPasskeys(getDb(), user.id);
|
let passkeys = await getUserPasskeys(db, user.id);
|
||||||
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.lastUsedAt).toBeNull();
|
expect(firstPasskey.lastUsedAt).toBeNull();
|
||||||
|
|
||||||
// Authenticate via router
|
// Authenticate via router
|
||||||
const { token: loginToken } = await createLoginRequest(user.id, user.email);
|
const { token: loginToken } = await createLoginRequest(
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options: authOptions, challengeId: authChallengeId } = await call(
|
const { options: authOptions, challengeId: authChallengeId } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -505,23 +538,31 @@ describe("authentication flow", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Check last_used_at is now set
|
// Check last_used_at is now set
|
||||||
passkeys = await getUserPasskeys(getDb(), user.id);
|
passkeys = await getUserPasskeys(db, user.id);
|
||||||
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.lastUsedAt).not.toBeNull();
|
expect(firstPasskey.lastUsedAt).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("cleans up challenge after authentication via router", async () => {
|
test("cleans up challenge after authentication via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "auth-cleanup@test.com",
|
email: "auth-cleanup@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register passkey via router
|
// Register passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Authenticate via router
|
// Authenticate via router
|
||||||
const { token: loginToken } = await createLoginRequest(user.id, user.email);
|
const { token: loginToken } = await createLoginRequest(
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options: authOptions, challengeId: authChallengeId } = await call(
|
const { options: authOptions, challengeId: authChallengeId } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -536,26 +577,34 @@ describe("authentication flow", () => {
|
|||||||
|
|
||||||
// Challenge should be deleted
|
// Challenge should be deleted
|
||||||
const challengeRow = await db
|
const challengeRow = await db
|
||||||
?.selectFrom("webauthn_challenges")
|
.selectFrom("webauthn_challenges")
|
||||||
.select("id")
|
.select("id")
|
||||||
.where("id", "=", String(authChallengeId))
|
.where("id", "=", String(authChallengeId))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
expect(challengeRow).toBeUndefined();
|
expect(challengeRow).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects unknown credential IDs", async () => {
|
test("rejects unknown credential IDs", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "unknown-cred@test.com",
|
email: "unknown-cred@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register passkey via router
|
// Register passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Create auth options via router
|
// Create auth options via router
|
||||||
const { token: loginToken } = await createLoginRequest(user.id, user.email);
|
const { token: loginToken } = await createLoginRequest(
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options: authOptions } = await call(
|
const { options: authOptions } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -567,7 +616,7 @@ describe("authentication flow", () => {
|
|||||||
origin: TEST_RP.origin,
|
origin: TEST_RP.origin,
|
||||||
});
|
});
|
||||||
// First create a credential so the authenticator has something (use same registration options)
|
// First create a credential so the authenticator has something (use same registration options)
|
||||||
const apiCtx = createAPIContext();
|
const apiCtx = createAPIContext(db);
|
||||||
const { options: regOptions } = await call(
|
const { options: regOptions } = await call(
|
||||||
router.auth.webauthn.createRegistrationOptions,
|
router.auth.webauthn.createRegistrationOptions,
|
||||||
{ email: user.email },
|
{ email: user.email },
|
||||||
@@ -586,31 +635,32 @@ describe("authentication flow", () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("security tests", () => {
|
describe("security tests", () => {
|
||||||
beforeAll(async () => {
|
|
||||||
await truncateAllTables(getDb());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects replayed credentials (counter check) via router", async () => {
|
test("rejects replayed credentials (counter check) via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "counter-replay@test.com",
|
email: "counter-replay@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register passkey via router
|
// Register passkey via router
|
||||||
const regResponse = await registerPasskey(
|
const regResponse = await registerPasskey(
|
||||||
|
db,
|
||||||
user.id,
|
user.id,
|
||||||
user.email,
|
user.email,
|
||||||
authenticator,
|
authenticator,
|
||||||
);
|
);
|
||||||
|
|
||||||
// First authentication should succeed
|
// First authentication should succeed
|
||||||
await authenticate(user.id, user.email, authenticator);
|
await authenticate(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Verify counter was updated to 1
|
// Verify counter was updated to 1
|
||||||
let passkeys = await getUserPasskeys(getDb(), user.id);
|
let passkeys = await getUserPasskeys(db, user.id);
|
||||||
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.counter).toBe(1);
|
expect(firstPasskey.counter).toBe(1);
|
||||||
|
|
||||||
@@ -618,8 +668,12 @@ describe("security tests", () => {
|
|||||||
authenticator.setSignCount(regResponse.id, 0);
|
authenticator.setSignCount(regResponse.id, 0);
|
||||||
|
|
||||||
// Create a new authentication challenge
|
// Create a new authentication challenge
|
||||||
const { token: loginToken } = await createLoginRequest(user.id, user.email);
|
const { token: loginToken } = await createLoginRequest(
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -643,23 +697,31 @@ describe("security tests", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Counter should not have changed
|
// Counter should not have changed
|
||||||
passkeys = await getUserPasskeys(getDb(), user.id);
|
passkeys = await getUserPasskeys(db, user.id);
|
||||||
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.counter).toBe(1);
|
expect(firstPasskey.counter).toBe(1);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects tampered authentication response", async () => {
|
test("rejects tampered authentication response", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "tampered-response@test.com",
|
email: "tampered-response@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register passkey via router
|
// Register passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Create authentication challenge
|
// Create authentication challenge
|
||||||
const { token: loginToken } = await createLoginRequest(user.id, user.email);
|
const { token: loginToken } = await createLoginRequest(
|
||||||
const loginCtx = createLoginRequestContext(loginToken);
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options, challengeId } = await call(
|
const { options, challengeId } = await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -709,71 +771,75 @@ describe("security tests", () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("full passkey lifecycle", () => {
|
describe("full passkey lifecycle", () => {
|
||||||
beforeAll(async () => {
|
test("register → authenticate → add second passkey → authenticate with either via router", async () => {
|
||||||
await truncateAllTables(getDb());
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, { email: "lifecycle@test.com" });
|
||||||
|
const authenticator1 = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
const authenticator2 = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
});
|
});
|
||||||
|
|
||||||
test("register → authenticate → add second passkey → authenticate with either via router", async () => {
|
|
||||||
const user = await createTestUser(getDb(), { email: "lifecycle@test.com" });
|
|
||||||
const authenticator1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
|
||||||
const authenticator2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
|
||||||
|
|
||||||
// Register first passkey via router
|
// Register first passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator1);
|
await registerPasskey(db, user.id, user.email, authenticator1);
|
||||||
|
|
||||||
// Authenticate with first passkey via router
|
// Authenticate with first passkey via router
|
||||||
await authenticate(user.id, user.email, authenticator1);
|
await authenticate(db, user.id, user.email, authenticator1);
|
||||||
|
|
||||||
// Register second passkey via router
|
// Register second passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator2);
|
await registerPasskey(db, user.id, user.email, authenticator2);
|
||||||
|
|
||||||
// Verify user now has 2 passkeys
|
// Verify user now has 2 passkeys
|
||||||
const passkeys = await getUserPasskeys(getDb(), user.id);
|
const passkeys = await getUserPasskeys(db, user.id);
|
||||||
expect(passkeys).toHaveLength(2);
|
expect(passkeys).toHaveLength(2);
|
||||||
|
|
||||||
// Authenticate with second passkey via router
|
// Authenticate with second passkey via router
|
||||||
await authenticate(user.id, user.email, authenticator2);
|
await authenticate(db, user.id, user.email, authenticator2);
|
||||||
|
|
||||||
// Authenticate with first passkey again via router
|
// Authenticate with first passkey again via router
|
||||||
await authenticate(user.id, user.email, authenticator1);
|
await authenticate(db, user.id, user.email, authenticator1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("register → authenticate multiple times → counter increments via router", async () => {
|
test("register → authenticate multiple times → counter increments via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "counter-test@test.com",
|
email: "counter-test@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
// Register passkey via router
|
// Register passkey via router
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Verify initial counter
|
// Verify initial counter
|
||||||
let passkeys = await getUserPasskeys(getDb(), user.id);
|
let passkeys = await getUserPasskeys(db, user.id);
|
||||||
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
let firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.counter).toBe(0);
|
expect(firstPasskey.counter).toBe(0);
|
||||||
|
|
||||||
// Authenticate 5 times via router
|
// Authenticate 5 times via router
|
||||||
for (let i = 1; i <= 5; i++) {
|
for (let i = 1; i <= 5; i++) {
|
||||||
await authenticate(user.id, user.email, authenticator);
|
await authenticate(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
// Verify counter incremented
|
// Verify counter incremented
|
||||||
passkeys = await getUserPasskeys(getDb(), user.id);
|
passkeys = await getUserPasskeys(db, user.id);
|
||||||
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.counter).toBe(i);
|
expect(firstPasskey.counter).toBe(i);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("passkey management", () => {
|
describe("passkey management", () => {
|
||||||
beforeAll(async () => {
|
|
||||||
await truncateAllTables(getDb());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("lists passkeys with correct data via router", async () => {
|
test("lists passkeys with correct data via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "list-passkeys@test.com",
|
email: "list-passkeys@test.com",
|
||||||
});
|
});
|
||||||
const authenticator1 = new VirtualAuthenticator({
|
const authenticator1 = new VirtualAuthenticator({
|
||||||
@@ -786,11 +852,11 @@ describe("passkey management", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Register two passkeys
|
// Register two passkeys
|
||||||
await registerPasskey(user.id, user.email, authenticator1);
|
await registerPasskey(db, user.id, user.email, authenticator1);
|
||||||
await registerPasskey(user.id, user.email, authenticator2);
|
await registerPasskey(db, user.id, user.email, authenticator2);
|
||||||
|
|
||||||
// List passkeys via router handler
|
// List passkeys via router handler
|
||||||
const ctx = await createUserAPIContext(user.id);
|
const ctx = await createUserAPIContext(db, user.id);
|
||||||
const passkeys = await call(router.me.passkeys.list, undefined, {
|
const passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
context: ctx,
|
context: ctx,
|
||||||
});
|
});
|
||||||
@@ -814,16 +880,20 @@ describe("passkey management", () => {
|
|||||||
throw new Error("Expected Google Password Manager passkey to exist");
|
throw new Error("Expected Google Password Manager passkey to exist");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("passkey stores correct device type and backup status", async () => {
|
test("passkey stores correct device type and backup status", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "device-type@test.com",
|
email: "device-type@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
const passkeys = await getUserPasskeys(getDb(), user.id);
|
const passkeys = await getUserPasskeys(db, user.id);
|
||||||
expect(passkeys).toHaveLength(1);
|
expect(passkeys).toHaveLength(1);
|
||||||
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
|
|
||||||
@@ -832,16 +902,20 @@ describe("passkey management", () => {
|
|||||||
expect(firstPasskey.backupEligible).toBe(false);
|
expect(firstPasskey.backupEligible).toBe(false);
|
||||||
expect(firstPasskey.backupStatus).toBe(false);
|
expect(firstPasskey.backupStatus).toBe(false);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("renames passkey successfully via router", async () => {
|
test("renames passkey successfully via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "rename-test@test.com",
|
email: "rename-test@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
const ctx = await createUserAPIContext(user.id);
|
const ctx = await createUserAPIContext(db, user.id);
|
||||||
let passkeys = await call(router.me.passkeys.list, undefined, {
|
let passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
context: ctx,
|
context: ctx,
|
||||||
});
|
});
|
||||||
@@ -858,27 +932,31 @@ describe("passkey management", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Verify name changed
|
// Verify name changed
|
||||||
passkeys = await call(router.me.passkeys.list, undefined, { context: ctx });
|
passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
});
|
||||||
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.name).toBe(newName);
|
expect(firstPasskey.name).toBe(newName);
|
||||||
expect(firstPasskey.name).not.toBe(originalName);
|
expect(firstPasskey.name).not.toBe(originalName);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("rename does not affect other user's passkeys", async () => {
|
test("rename does not affect other user's passkeys", async () => {
|
||||||
const user1 = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user1 = await createTestUser(db, {
|
||||||
email: "rename-user1@test.com",
|
email: "rename-user1@test.com",
|
||||||
});
|
});
|
||||||
const user2 = await createTestUser(getDb(), {
|
const user2 = await createTestUser(db, {
|
||||||
email: "rename-user2@test.com",
|
email: "rename-user2@test.com",
|
||||||
});
|
});
|
||||||
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
|
|
||||||
await registerPasskey(user1.id, user1.email, auth1);
|
await registerPasskey(db, user1.id, user1.email, auth1);
|
||||||
await registerPasskey(user2.id, user2.email, auth2);
|
await registerPasskey(db, user2.id, user2.email, auth2);
|
||||||
|
|
||||||
const ctx1 = await createUserAPIContext(user1.id);
|
const ctx1 = await createUserAPIContext(db, user1.id);
|
||||||
const ctx2 = await createUserAPIContext(user2.id);
|
const ctx2 = await createUserAPIContext(db, user2.id);
|
||||||
|
|
||||||
const user2Passkeys = await call(router.me.passkeys.list, undefined, {
|
const user2Passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
context: ctx2,
|
context: ctx2,
|
||||||
@@ -902,26 +980,34 @@ describe("passkey management", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User2's passkey should be unchanged
|
// User2's passkey should be unchanged
|
||||||
const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, {
|
const user2PasskeysAfter = await call(
|
||||||
|
router.me.passkeys.list,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
context: ctx2,
|
context: ctx2,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
const user2FirstPasskeyAfter = user2PasskeysAfter[0];
|
const user2FirstPasskeyAfter = user2PasskeysAfter[0];
|
||||||
if (!user2FirstPasskeyAfter) {
|
if (!user2FirstPasskeyAfter) {
|
||||||
throw new Error("Expected user2 passkey to exist after");
|
throw new Error("Expected user2 passkey to exist after");
|
||||||
}
|
}
|
||||||
expect(user2FirstPasskeyAfter.name).toBe(user2FirstPasskey.name);
|
expect(user2FirstPasskeyAfter.name).toBe(user2FirstPasskey.name);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: This test uses getSharedDb() directly because the delete passkey
|
||||||
|
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
|
||||||
test("deletes passkey when user has password via router", async () => {
|
test("deletes passkey when user has password via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
const db = getSharedDb();
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "delete-with-password@test.com",
|
email: "delete-with-password@test.com",
|
||||||
passwordHash: "fake-password-hash",
|
passwordHash: "fake-password-hash",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
|
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
const ctx = await createUserAPIContext(user.id);
|
const ctx = await createUserAPIContext(db, user.id);
|
||||||
let passkeys = await call(router.me.passkeys.list, undefined, {
|
let passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
context: ctx,
|
context: ctx,
|
||||||
});
|
});
|
||||||
@@ -937,17 +1023,20 @@ describe("passkey management", () => {
|
|||||||
expect(passkeys).toHaveLength(0);
|
expect(passkeys).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: This test uses getSharedDb() directly because the delete passkey
|
||||||
|
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
|
||||||
test("deletes passkey when user has multiple passkeys via router", async () => {
|
test("deletes passkey when user has multiple passkeys via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
const db = getSharedDb();
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "delete-multi@test.com",
|
email: "delete-multi@test.com",
|
||||||
});
|
});
|
||||||
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
|
|
||||||
await registerPasskey(user.id, user.email, auth1);
|
await registerPasskey(db, user.id, user.email, auth1);
|
||||||
await registerPasskey(user.id, user.email, auth2);
|
await registerPasskey(db, user.id, user.email, auth2);
|
||||||
|
|
||||||
const ctx = await createUserAPIContext(user.id);
|
const ctx = await createUserAPIContext(db, user.id);
|
||||||
let passkeys = await call(router.me.passkeys.list, undefined, {
|
let passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
context: ctx,
|
context: ctx,
|
||||||
});
|
});
|
||||||
@@ -969,16 +1058,19 @@ describe("passkey management", () => {
|
|||||||
expect(firstPasskey.id).not.toBe(firstPasskeyId);
|
expect(firstPasskey.id).not.toBe(firstPasskeyId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: This test uses getSharedDb() directly because the delete passkey
|
||||||
|
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
|
||||||
test("prevents deleting last passkey without password via router", async () => {
|
test("prevents deleting last passkey without password via router", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
const db = getSharedDb();
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "delete-last@test.com",
|
email: "delete-last@test.com",
|
||||||
// No password set
|
// No password set
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
|
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
const ctx = await createUserAPIContext(user.id);
|
const ctx = await createUserAPIContext(db, user.id);
|
||||||
const passkeys = await call(router.me.passkeys.list, undefined, {
|
const passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
context: ctx,
|
context: ctx,
|
||||||
});
|
});
|
||||||
@@ -1004,23 +1096,26 @@ describe("passkey management", () => {
|
|||||||
expect(passkeysAfter).toHaveLength(1);
|
expect(passkeysAfter).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: This test uses getSharedDb() directly because the delete passkey
|
||||||
|
// procedure internally uses db.transaction(), and Kysely doesn't support nested transactions.
|
||||||
test("delete does not affect other user's passkeys via router", async () => {
|
test("delete does not affect other user's passkeys via router", async () => {
|
||||||
const user1 = await createTestUser(getDb(), {
|
const db = getSharedDb();
|
||||||
|
const user1 = await createTestUser(db, {
|
||||||
email: "delete-user1@test.com",
|
email: "delete-user1@test.com",
|
||||||
passwordHash: "fake-hash",
|
passwordHash: "fake-hash",
|
||||||
});
|
});
|
||||||
const user2 = await createTestUser(getDb(), {
|
const user2 = await createTestUser(db, {
|
||||||
email: "delete-user2@test.com",
|
email: "delete-user2@test.com",
|
||||||
passwordHash: "fake-hash",
|
passwordHash: "fake-hash",
|
||||||
});
|
});
|
||||||
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
|
|
||||||
await registerPasskey(user1.id, user1.email, auth1);
|
await registerPasskey(db, user1.id, user1.email, auth1);
|
||||||
await registerPasskey(user2.id, user2.email, auth2);
|
await registerPasskey(db, user2.id, user2.email, auth2);
|
||||||
|
|
||||||
const ctx1 = await createUserAPIContext(user1.id);
|
const ctx1 = await createUserAPIContext(db, user1.id);
|
||||||
const ctx2 = await createUserAPIContext(user2.id);
|
const ctx2 = await createUserAPIContext(db, user2.id);
|
||||||
|
|
||||||
const user2Passkeys = await call(router.me.passkeys.list, undefined, {
|
const user2Passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
context: ctx2,
|
context: ctx2,
|
||||||
@@ -1051,16 +1146,17 @@ describe("passkey management", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("passkey credentialId is unique and stored correctly", async () => {
|
test("passkey credentialId is unique and stored correctly", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "credential-id@test.com",
|
email: "credential-id@test.com",
|
||||||
});
|
});
|
||||||
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth1 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const auth2 = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
||||||
|
|
||||||
await registerPasskey(user.id, user.email, auth1);
|
await registerPasskey(db, user.id, user.email, auth1);
|
||||||
await registerPasskey(user.id, user.email, auth2);
|
await registerPasskey(db, user.id, user.email, auth2);
|
||||||
|
|
||||||
const passkeys = await getUserPasskeys(getDb(), user.id);
|
const passkeys = await getUserPasskeys(db, user.id);
|
||||||
expect(passkeys).toHaveLength(2);
|
expect(passkeys).toHaveLength(2);
|
||||||
const firstPasskey = passkeys[0];
|
const firstPasskey = passkeys[0];
|
||||||
const secondPasskey = passkeys[1];
|
const secondPasskey = passkeys[1];
|
||||||
@@ -1075,16 +1171,20 @@ describe("passkey management", () => {
|
|||||||
expect(firstPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/);
|
expect(firstPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
expect(secondPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/);
|
expect(secondPasskey.credentialId).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("passkey transports are stored and retrieved correctly", async () => {
|
test("passkey transports are stored and retrieved correctly", async () => {
|
||||||
const user = await createTestUser(getDb(), {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
email: "transports@test.com",
|
email: "transports@test.com",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
await registerPasskey(user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
const passkeys = await getUserPasskeys(getDb(), user.id);
|
const passkeys = await getUserPasskeys(db, user.id);
|
||||||
expect(passkeys).toHaveLength(1);
|
expect(passkeys).toHaveLength(1);
|
||||||
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
const firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
|
|
||||||
@@ -1092,4 +1192,5 @@ describe("passkey management", () => {
|
|||||||
expect(firstPasskey.transports).toContain("internal");
|
expect(firstPasskey.transports).toContain("internal");
|
||||||
expect(firstPasskey.transports).toContain("hybrid");
|
expect(firstPasskey.transports).toContain("hybrid");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -238,3 +238,64 @@ export async function createTestUser(
|
|||||||
export async function destroyTestDb(db: Kysely<Database>): Promise<void> {
|
export async function destroyTestDb(db: Kysely<Database>): Promise<void> {
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Shared Database Singleton (for transaction-based test isolation)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let sharedDb: Kysely<Database> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the shared test database once.
|
||||||
|
* Runs migrations and truncates all tables to start with a clean slate.
|
||||||
|
* Subsequent calls return the existing connection.
|
||||||
|
*
|
||||||
|
* Use this with `withTestTransaction()` for fast test isolation.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* beforeAll(async () => {
|
||||||
|
* await initTestDb();
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* test("does something", async () => {
|
||||||
|
* await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
* // test code using db
|
||||||
|
* });
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function initTestDb(): Promise<Kysely<Database>> {
|
||||||
|
if (!sharedDb) {
|
||||||
|
await runMigrations();
|
||||||
|
sharedDb = createTestDb();
|
||||||
|
await truncateAllTables(sharedDb); // Clean slate once at start
|
||||||
|
}
|
||||||
|
return sharedDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the shared test database connection.
|
||||||
|
* Must call `initTestDb()` first.
|
||||||
|
*
|
||||||
|
* @throws Error if database not initialized
|
||||||
|
*/
|
||||||
|
export function getSharedDb(): Kysely<Database> {
|
||||||
|
if (!sharedDb) {
|
||||||
|
throw new Error(
|
||||||
|
"Test DB not initialized. Call initTestDb() in beforeAll first.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sharedDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy the shared test database connection.
|
||||||
|
* Call this in a global afterAll if needed.
|
||||||
|
*/
|
||||||
|
export async function destroySharedDb(): Promise<void> {
|
||||||
|
if (sharedDb) {
|
||||||
|
await sharedDb.destroy();
|
||||||
|
sharedDb = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
60
apps/api-server/src/__tests__/helpers/test-transaction.ts
Normal file
60
apps/api-server/src/__tests__/helpers/test-transaction.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Transaction-based test isolation helper
|
||||||
|
*
|
||||||
|
* Wraps test code in a transaction that auto-rollbacks, providing
|
||||||
|
* fast test isolation without truncating tables between tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Database } from "@reviq/db-schema";
|
||||||
|
import type { Kysely } from "kysely";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signal used to trigger transaction rollback after test completes
|
||||||
|
*/
|
||||||
|
class RollbackSignal extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("RollbackSignal");
|
||||||
|
this.name = "RollbackSignal";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a test function inside a transaction that auto-rollbacks.
|
||||||
|
*
|
||||||
|
* The transaction implements the same interface as Kysely<Database>,
|
||||||
|
* so it can be passed to context builders and used for all queries.
|
||||||
|
* After the test completes, the transaction is rolled back, providing
|
||||||
|
* instant cleanup without truncating tables.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* test("creates user", async () => {
|
||||||
|
* await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
* const user = await createTestUser(db, { email: "test@example.com" });
|
||||||
|
* const ctx = createAPIContext({ db });
|
||||||
|
* // ... test code
|
||||||
|
* }); // Auto-rollback here
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function withTestTransaction<T>(
|
||||||
|
db: Kysely<Database>,
|
||||||
|
testFn: (trx: Kysely<Database>) => Promise<T>,
|
||||||
|
): Promise<T | undefined> {
|
||||||
|
let result: T | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.transaction().execute(async (trx) => {
|
||||||
|
result = await testFn(trx);
|
||||||
|
// Force rollback by throwing after test completes successfully
|
||||||
|
throw new RollbackSignal();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Swallow the rollback signal - this is expected behavior
|
||||||
|
if (!(e instanceof RollbackSignal)) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
86
apps/cli/README.md
Normal file
86
apps/cli/README.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# RevIQ CLI
|
||||||
|
|
||||||
|
Command-line interface for RevIQ database and user management.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the CLI
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# The compiled binary will be at dist/reviq
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run directly with bun
|
||||||
|
bun run cli <command>
|
||||||
|
|
||||||
|
# Or use the compiled binary
|
||||||
|
./dist/reviq <command>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login with an API token
|
||||||
|
reviq auth login --token <your-token>
|
||||||
|
|
||||||
|
# Check authentication status
|
||||||
|
reviq auth status
|
||||||
|
|
||||||
|
# Logout
|
||||||
|
reviq auth logout
|
||||||
|
```
|
||||||
|
|
||||||
|
To get an API token:
|
||||||
|
1. Log in to the web dashboard
|
||||||
|
2. Go to Account Settings > API Tokens
|
||||||
|
3. Create a new token and copy it
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new user
|
||||||
|
reviq user create --email <email>
|
||||||
|
|
||||||
|
# Confirm email
|
||||||
|
reviq user confirm-email --code <code>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Organization Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List organizations
|
||||||
|
reviq org list
|
||||||
|
|
||||||
|
# Create an organization
|
||||||
|
reviq org create --name <name> --slug <slug>
|
||||||
|
|
||||||
|
# Add a site to an organization
|
||||||
|
reviq org add-site --org <slug> --domain <domain>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Complete login (admin)
|
||||||
|
reviq admin complete-login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bootstrap the database
|
||||||
|
reviq bootstrap
|
||||||
|
|
||||||
|
# Generate shell completions
|
||||||
|
reviq completions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Credentials are stored at `~/.config/reviq/credentials.json`.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
\restrict CIj4ub2A9kD8NQM2nKa1cg31hNutT3jXdOch0DnJ2bT48qpQKbe9XxNtViPwfYR
|
\restrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
|
||||||
|
|
||||||
-- Dumped from database version 17.7
|
-- Dumped from database version 17.7
|
||||||
-- Dumped by pg_dump version 17.7
|
-- Dumped by pg_dump version 17.7
|
||||||
@@ -1084,7 +1084,7 @@ ALTER TABLE ONLY public.user_devices
|
|||||||
-- PostgreSQL database dump complete
|
-- PostgreSQL database dump complete
|
||||||
--
|
--
|
||||||
|
|
||||||
\unrestrict CIj4ub2A9kD8NQM2nKa1cg31hNutT3jXdOch0DnJ2bT48qpQKbe9XxNtViPwfYR
|
\unrestrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
|
|||||||
Reference in New Issue
Block a user