feat(server): added api-keys (#248)

This commit is contained in:
Corentin Thomasset
2025-04-24 21:13:56 +02:00
committed by GitHub
parent 9cba84e38b
commit cc2edc59b0
60 changed files with 3380 additions and 145 deletions

View File

@@ -105,6 +105,9 @@ layout:
integrations: Integrations
deleted-documents: Deleted documents
organization-settings: Organization settings
api-keys: API keys
settings: Settings
account: Account
tagging-rules:
field:
@@ -217,3 +220,56 @@ api-errors:
intake_email.limit_reached: The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.
user.max_organization_count_reached: You have reached the maximum number of organizations you can create, if you need to create more, please contact support.
default: An error occurred while processing your request.
api-keys:
permissions:
documents:
title: Documents
documents:create: Create documents
documents:read: Read documents
documents:update: Update documents
documents:delete: Delete documents
tags:
title: Tags
tags:create: Create tags
tags:read: Read tags
tags:update: Update tags
tags:delete: Delete tags
create:
title: Create API key
description: Create a new API key to access the Papra API.
success: The API key has been created successfully.
back: Back to API keys
form:
name:
label: Name
placeholder: 'Example: My API key'
required: Please enter a name for the API key
permissions:
label: Permissions
description: Select the permissions for the API key.
required: Please select at least one permission
submit: Create API key
created:
title: API key created
description: The API key has been created successfully. Save it in a secure location as it will not be displayed again.
list:
title: API keys
description: Manage your API keys here.
delete: Delete
create: Create API key
empty:
title: No API keys
description: Create an API key to access the Papra API.
card:
last-used: Last used
never: Never
created: Created
delete: Delete
delete:
success: The API key has been deleted successfully
confirm:
title: Delete API key
message: Are you sure you want to delete this API key? This action cannot be undone.
confirm-button: Delete
cancel-button: Cancel

View File

@@ -0,0 +1,28 @@
// export const API_KEY_PERMISSIONS = {
// documents: {
// create: 'documents:create',
// },
// } as const;
export const API_KEY_PERMISSIONS = [
{
section: 'documents',
permissions: [
'documents:create',
'documents:read',
'documents:update',
'documents:delete',
],
},
{
section: 'tags',
permissions: [
'tags:create',
'tags:read',
'tags:update',
'tags:delete',
],
},
] as const;
export const API_KEY_PERMISSIONS_LIST = API_KEY_PERMISSIONS.flatMap(permission => permission.permissions);

View File

@@ -0,0 +1,56 @@
import type { ApiKey } from './api-keys.types';
import { apiClient } from '../shared/http/api-client';
import { coerceDates } from '../shared/http/http-client.models';
export async function createApiKey({
name,
permissions,
organizationIds,
allOrganizations,
expiresAt,
}: {
name: string;
permissions: string[];
organizationIds: string[];
allOrganizations: boolean;
expiresAt?: Date;
}) {
const { apiKey, token } = await apiClient<{
apiKey: ApiKey;
token: string;
}>({
path: '/api/api-keys',
method: 'POST',
body: {
name,
permissions,
organizationIds,
allOrganizations,
expiresAt,
},
});
return {
apiKey: coerceDates(apiKey),
token,
};
}
export async function fetchApiKeys() {
const { apiKeys } = await apiClient<{
apiKeys: ApiKey[];
}>({
path: '/api/api-keys',
});
return {
apiKeys: apiKeys.map(coerceDates),
};
}
export async function deleteApiKey({ apiKeyId }: { apiKeyId: string }) {
await apiClient({
path: `/api/api-keys/${apiKeyId}`,
method: 'DELETE',
});
}

View File

@@ -0,0 +1,12 @@
export type ApiKey = {
id: string;
name: string;
permissions: string[];
organizationIds: string[];
allOrganizations: boolean;
expiresAt?: Date;
prefix: string;
lastUsedAt?: Date;
createdAt: Date;
updatedAt: Date;
};

View File

@@ -0,0 +1,74 @@
import type { LocaleKeys } from '@/modules/i18n/locales.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { type Component, createSignal, For } from 'solid-js';
import { API_KEY_PERMISSIONS } from '../api-keys.constants';
export const ApiKeyPermissionsPicker: Component<{ permissions: string[]; onChange: (permissions: string[]) => void }> = (props) => {
const [permissions, setPermissions] = createSignal<string[]>(props.permissions);
const { t } = useI18n();
const getPermissionsSections = () => {
return API_KEY_PERMISSIONS.map(section => ({
...section,
title: t(`api-keys.permissions.${section.section}.title`),
permissions: section.permissions.map((permission) => {
const [prefix, suffix] = permission.split(':');
return {
name: permission,
prefix,
suffix,
description: t(`api-keys.permissions.${section.section}.${permission}` as LocaleKeys),
};
}),
}));
};
const isPermissionSelected = (permission: string) => {
return permissions().includes(permission);
};
const togglePermission = (permission: string) => {
setPermissions((prev) => {
if (prev.includes(permission)) {
return prev.filter(p => p !== permission);
}
return [...prev, permission];
});
props.onChange(permissions());
};
return (
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<For each={getPermissionsSections()}>
{section => (
<div>
<p class="text-muted-foreground text-xs">{section.title}</p>
<div class="pl-4 flex flex-col gap-4 mt-4">
<For each={section.permissions}>
{permission => (
<Checkbox
class="flex items-center gap-2"
checked={isPermissionSelected(permission.name)}
onChange={() => togglePermission(permission.name)}
>
<CheckboxControl />
<div class="flex flex-col gap-1">
<CheckboxLabel class="text-sm leading-none">
{permission.description}
</CheckboxLabel>
</div>
</Checkbox>
)}
</For>
</div>
</div>
)}
</For>
</div>
);
};

View File

@@ -0,0 +1,139 @@
import type { ApiKey } from '../api-keys.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { queryClient } from '@/modules/shared/query/query-client';
import { Button } from '@/modules/ui/components/button';
import { EmptyState } from '@/modules/ui/components/empty';
import { createToast } from '@/modules/ui/components/sonner';
import { A } from '@solidjs/router';
import { createMutation, createQuery } from '@tanstack/solid-query';
import { format } from 'date-fns';
import { type Component, For, Match, Show, Suspense, Switch } from 'solid-js';
import { deleteApiKey, fetchApiKeys } from '../api-keys.services';
export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
const { t } = useI18n();
const { confirm } = useConfirmModal();
const deleteApiKeyMutation = createMutation(() => ({
mutationFn: deleteApiKey,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['api-keys'] });
createToast({
message: t('api-keys.delete.success'),
});
},
}));
const handleDelete = async () => {
const confirmed = await confirm({
title: t('api-keys.delete.confirm.title'),
message: t('api-keys.delete.confirm.message'),
confirmButton: {
text: t('api-keys.delete.confirm.confirm-button'),
variant: 'destructive',
},
cancelButton: {
text: t('api-keys.delete.confirm.cancel-button'),
},
});
if (!confirmed) {
return;
}
deleteApiKeyMutation.mutate({ apiKeyId: apiKey.id });
};
return (
<div class="bg-card rounded-lg border p-4 flex items-center gap-4">
<div class="rounded-lg bg-muted p-2">
<div class="i-tabler-key text-muted-foreground size-5 text-primary" />
</div>
<div class="flex-1">
<h2 class="text-sm font-medium leading-tight">{apiKey.name}</h2>
<p class="text-muted-foreground text-xs font-mono">{`${apiKey.prefix}...`}</p>
</div>
<div>
<p class="text-muted-foreground text-xs">
{t('api-keys.list.card.last-used')}
{' '}
{apiKey.lastUsedAt ? format(apiKey.lastUsedAt, 'MMM d, yyyy') : t('api-keys.list.card.never')}
</p>
<p class="text-muted-foreground text-xs">
{t('api-keys.list.card.created')}
{' '}
{format(apiKey.createdAt, 'MMM d, yyyy')}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="icon"
isLoading={deleteApiKeyMutation.isPending}
onClick={handleDelete}
>
<div class="i-tabler-trash text-muted-foreground size-4" />
</Button>
</div>
</div>
);
};
export const ApiKeysPage: Component = () => {
const { t } = useI18n();
const query = createQuery(() => ({
queryKey: ['api-keys'],
queryFn: () => fetchApiKeys(),
}));
return (
<div class="p-6 mt-12 pb-32 mx-auto max-w-xl w-full">
<div class="border-b pb-4 flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold mb-1">{t('api-keys.list.title')}</h1>
<p class="text-muted-foreground">{t('api-keys.list.description')}</p>
</div>
<div>
<Show when={query.data?.apiKeys?.length}>
<Button as={A} href="/api-keys/create" class="gap-2">
<div class="i-tabler-plus size-4" />
{t('api-keys.list.create')}
</Button>
</Show>
</div>
</div>
<Suspense>
<Switch>
<Match when={query.data?.apiKeys?.length === 0}>
<EmptyState
title={t('api-keys.list.empty.title')}
description={t('api-keys.list.empty.description')}
icon="i-tabler-key"
cta={(
<Button as={A} href="/api-keys/create" class="gap-2">
<div class="i-tabler-plus size-4" />
{t('api-keys.list.create')}
</Button>
)}
/>
</Match>
<Match when={query.data?.apiKeys?.length}>
<div class="mt-6 flex flex-col gap-2">
<For each={query.data?.apiKeys}>
{apiKey => (
<ApiKeyCard apiKey={apiKey} />
)}
</For>
</div>
</Match>
</Switch>
</Suspense>
</div>
);
};

View File

@@ -0,0 +1,117 @@
import { useI18n } from '@/modules/i18n/i18n.provider';
import { createForm } from '@/modules/shared/form/form';
import { queryClient } from '@/modules/shared/query/query-client';
import { CopyButton } from '@/modules/shared/utils/copy';
import { Button } from '@/modules/ui/components/button';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { setValue } from '@modular-forms/solid';
import { A } from '@solidjs/router';
import { type Component, createSignal, Show } from 'solid-js';
import * as v from 'valibot';
import { API_KEY_PERMISSIONS_LIST } from '../api-keys.constants';
import { createApiKey } from '../api-keys.services';
import { ApiKeyPermissionsPicker } from '../components/api-key-permissions-picker.component';
export const CreateApiKeyPage: Component = () => {
const { t } = useI18n();
const [getToken, setToken] = createSignal<string | null>(null);
const { form, Form, Field } = createForm({
onSubmit: async ({ name, permissions }) => {
const { token } = await createApiKey({
name,
permissions,
organizationIds: [],
allOrganizations: false,
});
await queryClient.invalidateQueries({ queryKey: ['api-keys'] });
setToken(token);
createToast({
type: 'success',
message: t('api-keys.create.success'),
});
},
schema: v.object({
name: v.pipe(
v.string(),
v.nonEmpty(t('api-keys.create.form.name.required')),
),
permissions: v.pipe(
v.array(v.picklist(API_KEY_PERMISSIONS_LIST as string[])),
v.nonEmpty(t('api-keys.create.form.permissions.required')),
),
}),
initialValues: {
name: '',
permissions: API_KEY_PERMISSIONS_LIST,
},
});
return (
<div class="p-6 mt-12 pb-32 mx-auto max-w-xl w-full">
<div class="border-b pb-4 mb-6">
<h1 class="text-2xl font-bold">{t('api-keys.create.title')}</h1>
<p class="text-sm text-muted-foreground">{t('api-keys.create.description')}</p>
</div>
<Show when={getToken()}>
<div class="bg-card border p-6 rounded-md mt-6">
<h2 class="text-lg font-semibold mb-2">{t('api-keys.create.created.title')}</h2>
<p class="text-sm text-muted-foreground mb-4">{t('api-keys.create.created.description')}</p>
<TextFieldRoot class="flex items-center gap-2 space-y-0">
<TextField type="text" placeholder={t('api-keys.create.form.name.placeholder')} value={getToken() ?? ''} />
<CopyButton text={getToken() ?? ''} />
</TextFieldRoot>
</div>
<div class="flex justify-end mt-6">
<Button type="button" variant="secondary" as={A} href="/api-keys">
{t('api-keys.create.back')}
</Button>
</div>
</Show>
<Show when={!getToken()}>
<Form>
<Field name="name">
{(field, inputProps) => (
<TextFieldRoot class="flex flex-col mb-6">
<TextFieldLabel for="name">{t('api-keys.create.form.name.label')}</TextFieldLabel>
<TextField type="text" id="name" placeholder={t('api-keys.create.form.name.placeholder')} {...inputProps} autoFocus value={field.value} aria-invalid={Boolean(field.error)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</TextFieldRoot>
)}
</Field>
<Field name="permissions" type="string[]">
{field => (
<div>
<p class="text-sm font-bold">{t('api-keys.create.form.permissions.label')}</p>
<div class="p-6 pb-8 border rounded-md mt-2">
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
</div>
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</div>
)}
</Field>
<div class="flex justify-end mt-6">
<Button type="submit" isLoading={form.submitting}>
{t('api-keys.create.form.submit')}
</Button>
</div>
</Form>
</Show>
</div>
);
};

View File

@@ -1,10 +1,29 @@
import type { ApiKey } from '../api-keys/api-keys.types';
import { get } from 'lodash-es';
import { FetchError } from 'ofetch';
import { createRouter } from 'radix3';
import { defineHandler } from './demo-api-mock.models';
import { documentFileStorage, documentStorage, organizationStorage, tagDocumentStorage, taggingRuleStorage, tagStorage } from './demo.storage';
import {
apiKeyStorage,
documentFileStorage,
documentStorage,
organizationStorage,
tagDocumentStorage,
taggingRuleStorage,
tagStorage,
} from './demo.storage';
import { findMany, getValues } from './demo.storage.models';
const corpus = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
function randomString({ length = 10 }: { length?: number } = {}) {
return Array.from({ length }, () => corpus[Math.floor(Math.random() * corpus.length)]).join('');
}
function createId({ prefix }: { prefix: string }) {
return `${prefix}_${randomString({ length: 24 })}`;
}
function assert(condition: unknown, { message = 'Error', status }: { message?: string; status?: number } = {}): asserts condition {
if (!condition) {
throw Object.assign(new FetchError(message), { status });
@@ -114,7 +133,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(file, { status: 400 });
const document = {
id: `doc_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'doc' }),
organizationId,
name: file.name,
originalName: file.name,
@@ -311,7 +330,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(organization, { status: 403 });
const tag = {
id: `tag_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'tag' }),
organizationId,
name: get(body, 'name'),
color: get(body, 'color'),
@@ -373,7 +392,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
assert(tagId, { status: 400 });
const tagDocument = {
id: `tagDoc_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'tagDoc' }),
tagId,
documentId,
createdAt: new Date(),
@@ -412,7 +431,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
method: 'POST',
handler: async ({ body }) => {
const organization = {
id: `org_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'org' }),
name: get(body, 'name'),
createdAt: new Date(),
updatedAt: new Date(),
@@ -476,7 +495,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
method: 'POST',
handler: async ({ params: { organizationId }, body }) => {
const taggingRule = {
id: `tr_${Math.random().toString(36).slice(2)}`,
id: createId({ prefix: 'tr' }),
organizationId,
name: get(body, 'name'),
description: get(body, 'description'),
@@ -545,6 +564,48 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
await documentStorage.removeItem(key);
},
}),
...defineHandler({
path: '/api/api-keys',
method: 'GET',
handler: async () => {
const apiKeys = await getValues(apiKeyStorage);
return { apiKeys };
},
}),
...defineHandler({
path: '/api/api-keys',
method: 'POST',
handler: async ({ body }) => {
const token = `ppapi_${randomString({ length: 64 })}`;
const apiKey = {
id: createId({ prefix: 'apiKey' }),
name: get(body, 'name'),
permissions: get(body, 'permissions'),
organizationIds: get(body, 'organizationIds'),
allOrganizations: get(body, 'allOrganizations'),
expiresAt: get(body, 'expiresAt'),
createdAt: new Date(),
updatedAt: new Date(),
prefix: token.slice(0, 11),
} as ApiKey;
await apiKeyStorage.setItem(apiKey.id, apiKey);
return { apiKey, token };
},
}),
...defineHandler({
path: '/api/api-keys/:apiKeyId',
method: 'DELETE',
handler: async ({ params: { apiKeyId } }) => {
await apiKeyStorage.removeItem(apiKeyId);
},
}),
};
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });

View File

@@ -1,3 +1,4 @@
import type { ApiKey } from '../api-keys/api-keys.types';
import type { Document } from '../documents/documents.types';
import type { Organization } from '../organizations/organizations.types';
import type { TaggingRule } from '../tagging-rules/tagging-rules.types';
@@ -16,6 +17,7 @@ export const documentFileStorage = prefixStorage(storage, 'documentFiles');
export const tagStorage = prefixStorage<Omit<Tag, 'documentsCount'>>(storage, 'tags');
export const tagDocumentStorage = prefixStorage<{ documentId: string; tagId: string; id: string }>(storage, 'tagDocuments');
export const taggingRuleStorage = prefixStorage<TaggingRule>(storage, 'taggingRules');
export const apiKeyStorage = prefixStorage<ApiKey>(storage, 'apiKeys');
export async function clearDemoStorage() {
await storage.clear();

File diff suppressed because one or more lines are too long

View File

@@ -24,6 +24,8 @@ describe('http-client models', () => {
createdAt: '2021-01-01T00:00:00.000Z',
updatedAt: '2021-01-02T00:00:00.000Z',
deletedAt: '2021-01-03T00:00:00.000Z',
expiresAt: '2021-01-04T00:00:00.000Z',
lastUsedAt: '2021-01-05T00:00:00.000Z',
foo: 'bar',
baz: 'qux',
};
@@ -34,6 +36,8 @@ describe('http-client models', () => {
createdAt: new Date('2021-01-01T00:00:00.000Z'),
updatedAt: new Date('2021-01-02T00:00:00.000Z'),
deletedAt: new Date('2021-01-03T00:00:00.000Z'),
expiresAt: new Date('2021-01-04T00:00:00.000Z'),
lastUsedAt: new Date('2021-01-05T00:00:00.000Z'),
foo: 'bar',
baz: 'qux',
});

View File

@@ -24,5 +24,7 @@ export function coerceDates<T extends Record<string, any>>(obj: T): CoerceDates<
...('createdAt' in obj ? { createdAt: toDate(obj.createdAt) } : {}),
...('updatedAt' in obj ? { updatedAt: toDate(obj.updatedAt) } : {}),
...('deletedAt' in obj ? { deletedAt: toDate(obj.deletedAt) } : {}),
...('expiresAt' in obj ? { expiresAt: toDate(obj.expiresAt) } : {}),
...('lastUsedAt' in obj ? { lastUsedAt: toDate(obj.lastUsedAt) } : {}),
};
}

View File

@@ -1,34 +1,47 @@
import type { ParentComponent } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { A } from '@solidjs/router';
import { Button } from '../components/button';
import { SideNav } from './sidenav.layout';
export const SettingsLayout: ParentComponent = (props) => {
const navigation = [
const { t } = useI18n();
const getMainMenuItems = () => [
{
name: 'Account',
href: '/settings/account',
label: t('layout.menu.account'),
icon: 'i-tabler-user',
href: '/settings',
},
{
name: 'Billing & Plan',
href: '/settings/billing',
label: t('layout.menu.api-keys'),
icon: 'i-tabler-key',
href: '/api-keys',
},
];
return (
<div class="p-6 max-w-5xl mx-auto mt-4 pb-32">
<div class="flex justify-between items-center mb-2">
<h1 class="text-2xl font-bold">Settings</h1>
</div>
<div class="flex flex-row h-screen min-h-0">
<div class="w-350px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
<div class="flex gap-6 mb-6 border-b">
{navigation.map(item => (
<Button as={A} href={item.href} variant="ghost" class="px-0 border-b border-transparent text-muted-foreground rounded-b-none !bg-transparent" activeClass="!text-foreground !border-foreground">
{item.name}
</Button>
))}
</div>
<SideNav
mainMenu={getMainMenuItems()}
header={() => (
<div class="pl-6 py-3 border-b border-b-border flex items-center gap-1">
<Button variant="ghost" size="icon" class="text-muted-foreground" as={A} href="/">
<div class="i-tabler-arrow-left size-5"></div>
</Button>
<h1 class="text-lg font-bold">
{t('layout.menu.settings')}
</h1>
</div>
)}
/>
{props.children}
</div>
<div class="flex-1 min-h-0 flex flex-col">
{props.children}
</div>
</div>
);
};

View File

@@ -242,6 +242,11 @@ export const SidenavLayout: ParentComponent<{
Account settings
</DropdownMenuItem>
<DropdownMenuItem class="flex items-center gap-2 cursor-pointer" as={A} href="/api-keys">
<div class="i-tabler-key size-4 text-muted-foreground"></div>
API keys
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-language size-4 text-muted-foreground"></div>

View File

@@ -4,7 +4,7 @@ import { Button } from '@/modules/ui/components/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/modules/ui/components/card';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { A, useNavigate } from '@solidjs/router';
import { useNavigate } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createSignal, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
@@ -127,18 +127,15 @@ export const UserSettingsPage: Component = () => {
}));
return (
<div class="p-6 mt-4 pb-32 mx-auto max-w-xl">
<div class="p-6 mt-12 pb-32 mx-auto max-w-xl">
<Suspense>
<Show when={query.data?.user}>
{getUser => (
<>
<Button as={A} href="/" variant="outline" class="mb-4">
<div class="i-tabler-arrow-left size-4 mr-2"></div>
Back
</Button>
<h1 class="text-xl font-semibold mb-2">User settings</h1>
<p class="text-muted-foreground">Manage your account settings here.</p>
<div class="border-b pb-4">
<h1 class="text-2xl font-semibold mb-1">User settings</h1>
<p class="text-muted-foreground">Manage your account settings here.</p>
</div>
<div class="mt-6 flex flex-col gap-6">
<UserEmailCard email={getUser().email} />

View File

@@ -1,6 +1,8 @@
import { Navigate, type RouteDefinition, useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { Match, Show, Suspense, Switch } from 'solid-js';
import { ApiKeysPage } from './modules/api-keys/pages/api-keys.page';
import { CreateApiKeyPage } from './modules/api-keys/pages/create-api-key.page';
import { createProtectedPage } from './modules/auth/middleware/protected-page.middleware';
import { EmailValidationRequiredPage } from './modules/auth/pages/email-validation-required.page';
import { LoginPage } from './modules/auth/pages/login.page';
@@ -25,6 +27,7 @@ import { UpdateTaggingRulePage } from './modules/tagging-rules/pages/update-tagg
import { TagsPage } from './modules/tags/pages/tags.page';
import { IntegrationsLayout } from './modules/ui/layouts/integrations.layout';
import { OrganizationLayout } from './modules/ui/layouts/organization.layout';
import { SettingsLayout } from './modules/ui/layouts/settings.layout';
import { CurrentUserProvider, useCurrentUser } from './modules/users/composables/useCurrentUser';
import { UserSettingsPage } from './modules/users/pages/user-settings.page';
@@ -155,8 +158,22 @@ export const routes: RouteDefinition[] = [
],
},
{
path: '/settings',
component: UserSettingsPage,
path: '/',
component: SettingsLayout,
children: [
{
path: '/settings',
component: UserSettingsPage,
},
{
path: '/api-keys',
component: ApiKeysPage,
},
{
path: '/api-keys/create',
component: CreateApiKeyPage,
},
],
},
{
path: '/login',

View File

@@ -17,7 +17,7 @@ export default defineConfig({
server: {
port: 3000,
proxy: {
'/api': {
'/api/': {
target: 'http://localhost:1221',
},
},

View File

@@ -0,0 +1,24 @@
CREATE TABLE `api_key_organizations` (
`api_key_id` text NOT NULL,
`organization_member_id` text NOT NULL,
FOREIGN KEY (`api_key_id`) REFERENCES `api_keys`(`id`) ON UPDATE cascade ON DELETE cascade,
FOREIGN KEY (`organization_member_id`) REFERENCES `organization_members`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `api_keys` (
`id` text PRIMARY KEY NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`name` text NOT NULL,
`key_hash` text NOT NULL,
`prefix` text NOT NULL,
`user_id` text NOT NULL,
`last_used_at` integer,
`expires_at` integer,
`permissions` text DEFAULT '[]' NOT NULL,
`all_organizations` integer DEFAULT false NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint
CREATE INDEX `key_hash_index` ON `api_keys` (`key_hash`);

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,13 @@
"when": 1743938048080,
"tag": "0002_tagging_rules",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1745131802627,
"tag": "0003_api-keys",
"breakpoints": true
}
]
}

View File

@@ -51,6 +51,7 @@
"hono": "^4.6.15",
"lodash-es": "^4.17.21",
"mime-types": "^3.0.1",
"nanoid": "^5.1.5",
"node-cron": "^3.0.3",
"p-limit": "^6.2.0",
"p-queue": "^8.1.0",

View File

@@ -0,0 +1,19 @@
export const API_KEY_PREFIX = 'ppapi';
export const API_KEY_TOKEN_LENGTH = 64;
export const API_KEY_PERMISSIONS = {
DOCUMENTS: {
CREATE: 'documents:create',
READ: 'documents:read',
UPDATE: 'documents:update',
DELETE: 'documents:delete',
},
TAGS: {
CREATE: 'tags:create',
READ: 'tags:read',
UPDATE: 'tags:update',
DELETE: 'tags:delete',
},
} as const;
export const API_KEY_PERMISSIONS_VALUES = Object.values(API_KEY_PERMISSIONS).flatMap(permissions => Object.values(permissions));

View File

@@ -0,0 +1,43 @@
import type { Database } from '../app/database/database.types';
import type { Context } from '../app/server.types';
import { createMiddleware } from 'hono/factory';
import { createUnauthorizedError } from '../app/auth/auth.errors';
import { getAuthorizationHeader } from '../shared/headers/headers.models';
import { createApiKeysRepository } from './api-keys.repository';
import { getApiKey } from './api-keys.usecases';
// The role of this middleware is to extract the api key from the authorization header if present
// and set it on the context, no auth enforcement is done here
export function createApiKeyMiddleware({ db }: { db: Database }) {
const apiKeyRepository = createApiKeysRepository({ db });
return createMiddleware(async (context: Context, next) => {
const { authorizationHeader } = getAuthorizationHeader({ context });
if (!authorizationHeader) {
return next();
}
const parts = authorizationHeader.split(' ');
if (parts.length !== 2) {
throw createUnauthorizedError();
}
const [maybeBearer, token] = parts;
if (maybeBearer !== 'Bearer') {
throw createUnauthorizedError();
}
const { apiKey } = await getApiKey({ token, apiKeyRepository });
if (apiKey) {
context.set('apiKey', apiKey);
context.set('userId', apiKey.userId);
context.set('authType', 'api-key');
}
await next();
});
}

View File

@@ -0,0 +1,14 @@
import { describe, expect, test } from 'vitest';
import { getApiKeyUiPrefix } from './api-keys.models';
describe('api-keys models', () => {
describe('getApiKeyUiPrefix', () => {
test('the prefix is what the user will see in the ui in order to identify the api key, it is the first 5 characters of the token regardless of the token prefix', () => {
expect(
getApiKeyUiPrefix({ token: 'ppapi_29qxv9eCbRkQQGhwrVZCEXEFjOYpXZX07G4vDK4HT03Jp7fVHyJx1b0l6e1LIEPD' }),
).to.eql(
{ prefix: 'ppapi_29qxv' },
);
});
});
});

View File

@@ -0,0 +1,14 @@
import { sha256 } from '../shared/crypto/hash';
import { API_KEY_PREFIX } from './api-keys.constants';
export function getApiKeyUiPrefix({ token }: { token: string }) {
return {
prefix: token.slice(0, 5 + API_KEY_PREFIX.length + 1),
};
}
export function getApiKeyHash({ token }: { token: string }) {
return {
keyHash: sha256(token, { digest: 'base64url' }),
};
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from '../app/database/database.test-utils';
import { ORGANIZATION_ROLES } from '../organizations/organizations.constants';
import { createApiKeysRepository } from './api-keys.repository';
describe('api-keys repository', () => {
describe('getUserApiKeys', () => {
test('when retrieving api keys, it returns the api keys with the organizations they are linked to', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'user-1', email: 'user-1@example.com' },
],
organizations: [
{ id: 'organization-1', name: 'Organization 1', createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-02') },
{ id: 'organization-2', name: 'Organization 2', createdAt: new Date('2021-02-01'), updatedAt: new Date('2021-02-02') },
],
organizationMembers: [
{ id: 'om-1', organizationId: 'organization-1', userId: 'user-1', role: ORGANIZATION_ROLES.OWNER },
],
apiKeys: [
{ id: 'api-key-1', userId: 'user-1', name: 'API Key 1', prefix: 'ak', keyHash: 'hash1', createdAt: new Date('2021-03-01'), updatedAt: new Date('2021-03-02') },
],
apiKeyOrganizations: [
{ apiKeyId: 'api-key-1', organizationMemberId: 'om-1' },
],
});
const repository = createApiKeysRepository({ db });
const { apiKeys } = await repository.getUserApiKeys({ userId: 'user-1' });
expect(apiKeys).to.eql([
{
id: 'api-key-1',
userId: 'user-1',
name: 'API Key 1',
allOrganizations: false,
prefix: 'ak',
lastUsedAt: null,
expiresAt: null,
organizations: [{
id: 'organization-1',
name: 'Organization 1',
customerId: null,
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-02'),
}],
createdAt: new Date('2021-03-01'),
updatedAt: new Date('2021-03-02'),
permissions: [],
},
]);
});
});
});

View File

@@ -0,0 +1,149 @@
import type { Database } from '../app/database/database.types';
import type { Logger } from '../shared/logger/logger';
import type { ApiKeyPermissions } from './api-keys.types';
import { injectArguments } from '@corentinth/chisels';
import { and, eq, getTableColumns, inArray } from 'drizzle-orm';
import { omit, pick } from 'lodash-es';
import { organizationMembersTable, organizationsTable } from '../organizations/organizations.table';
import { createLogger } from '../shared/logger/logger';
import { apiKeyOrganizationsTable, apiKeysTable } from './api-keys.tables';
export type ApiKeysRepository = ReturnType<typeof createApiKeysRepository>;
export function createApiKeysRepository({ db, logger = createLogger({ namespace: 'api-keys.repository' }) }: { db: Database; logger?: Logger }) {
return injectArguments(
{
saveApiKey,
getUserApiKeys,
deleteUserApiKey,
getApiKeyByHash,
},
{ db, logger },
);
}
async function saveApiKey({
db,
logger,
name,
keyHash,
prefix,
permissions,
allOrganizations,
userId,
organizationIds,
expiresAt,
}: {
db: Database;
logger: Logger;
name: string;
keyHash: string;
prefix: string;
permissions: ApiKeyPermissions[];
allOrganizations: boolean;
organizationIds: string[] | undefined;
expiresAt?: Date;
userId: string;
}) {
const [apiKey] = await db
.insert(apiKeysTable)
.values({
name,
keyHash,
prefix,
permissions,
allOrganizations,
userId,
expiresAt,
})
.returning();
if (organizationIds && organizationIds.length > 0) {
const apiKeyId = apiKey.id;
const organizationMembers = await db
.select()
.from(organizationMembersTable)
.where(
and(
inArray(organizationMembersTable.organizationId, organizationIds),
eq(organizationMembersTable.userId, userId),
),
);
if (!organizationIds.every(id => organizationMembers.some(om => om.organizationId === id))) {
logger.warn({
userId,
organizationIds,
organizationMembers: organizationMembers.map(om => pick(om, ['id', 'organizationId', 'userId', 'role'])),
}, 'Api key created for organization that the user is not part of');
}
await db
.insert(apiKeyOrganizationsTable)
.values(
organizationMembers.map(({ id: organizationMemberId }) => ({ apiKeyId, organizationMemberId })),
);
}
return { apiKey };
}
async function getUserApiKeys({ userId, db }: { userId: string; db: Database }) {
const apiKeys = await db
.select({
...omit(getTableColumns(apiKeysTable), 'keyHash'),
})
.from(apiKeysTable)
.where(
eq(apiKeysTable.userId, userId),
);
const relatedOrganizations = await db
.select({
...getTableColumns(organizationsTable),
apiKeyId: apiKeyOrganizationsTable.apiKeyId,
})
.from(apiKeyOrganizationsTable)
.leftJoin(organizationMembersTable, eq(apiKeyOrganizationsTable.organizationMemberId, organizationMembersTable.id))
.leftJoin(organizationsTable, eq(organizationMembersTable.organizationId, organizationsTable.id))
.where(
and(
inArray(apiKeyOrganizationsTable.apiKeyId, apiKeys.map(apiKey => apiKey.id)),
eq(organizationMembersTable.userId, userId),
),
);
const apiKeysWithOrganizations = apiKeys.map(apiKey => ({
...apiKey,
organizations: relatedOrganizations
.filter(organization => organization.apiKeyId === apiKey.id)
.map(({ apiKeyId: _, ...organization }) => organization),
}));
return {
apiKeys: apiKeysWithOrganizations,
};
}
async function deleteUserApiKey({ apiKeyId, userId, db }: { apiKeyId: string; userId: string; db: Database }) {
await db
.delete(apiKeysTable)
.where(
and(
eq(apiKeysTable.id, apiKeyId),
eq(apiKeysTable.userId, userId),
),
);
}
async function getApiKeyByHash({ keyHash, db }: { keyHash: string; db: Database }) {
const [apiKey] = await db
.select()
.from(apiKeysTable)
.where(
eq(apiKeysTable.keyHash, keyHash),
);
return { apiKey };
}

View File

@@ -0,0 +1,99 @@
import type { RouteDefinitionContext } from '../app/server.types';
import type { ApiKeyPermissions } from './api-keys.types';
import { z } from 'zod';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { createError } from '../shared/errors/errors';
import { validateJsonBody } from '../shared/validation/validation';
import { API_KEY_PERMISSIONS_VALUES } from './api-keys.constants';
import { createApiKeysRepository } from './api-keys.repository';
import { createApiKey } from './api-keys.usecases';
export function registerApiKeysRoutes(context: RouteDefinitionContext) {
setupCreateApiKeyRoute(context);
setupGetApiKeysRoute(context);
setupDeleteApiKeyRoute(context);
}
function setupCreateApiKeyRoute({ app, db }: RouteDefinitionContext) {
app.post(
'/api/api-keys',
requireAuthentication(),
validateJsonBody(
z.object({
name: z.string(),
permissions: z.array(z.enum(API_KEY_PERMISSIONS_VALUES as [ApiKeyPermissions, ...ApiKeyPermissions[]])).min(1),
organizationIds: z.array(z.string()).default([]),
allOrganizations: z.boolean().default(false),
expiresAt: z.date().optional(),
}),
),
async (context) => {
const { userId } = getUser({ context });
const apiKeyRepository = createApiKeysRepository({ db });
const {
name,
permissions,
organizationIds,
allOrganizations,
expiresAt,
} = context.req.valid('json');
if (allOrganizations && organizationIds.length > 0) {
throw createError({
code: 'api_keys.invalid_organization_ids',
message: 'No organizationIds should be provided if allOrganizations is true',
statusCode: 400,
});
}
const { apiKey, token } = await createApiKey({
name,
permissions,
organizationIds,
allOrganizations,
expiresAt,
userId,
apiKeyRepository,
});
return context.json({
apiKey,
token,
});
},
);
}
function setupGetApiKeysRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/api-keys',
requireAuthentication(),
async (context) => {
const { userId } = getUser({ context });
const apiKeyRepository = createApiKeysRepository({ db });
const { apiKeys } = await apiKeyRepository.getUserApiKeys({ userId });
return context.json({ apiKeys });
},
);
}
function setupDeleteApiKeyRoute({ app, db }: RouteDefinitionContext) {
app.delete(
'/api/api-keys/:apiKeyId',
requireAuthentication(),
async (context) => {
const { userId } = getUser({ context });
const apiKeyRepository = createApiKeysRepository({ db });
const { apiKeyId } = context.req.param();
await apiKeyRepository.deleteUserApiKey({ apiKeyId, userId });
return context.body(null, 204);
},
);
}

View File

@@ -0,0 +1,12 @@
import { describe, expect, test } from 'vitest';
import { generateApiToken } from './api-keys.services';
describe('api-keys services', () => {
describe('generateApiToken', () => {
test('api token is a 64 random alphanumeric characters string prefixed with "ppapi_",', () => {
const { token } = generateApiToken();
expect(token).toMatch(/^ppapi_[a-zA-Z0-9]{64}$/);
});
});
});

View File

@@ -0,0 +1,8 @@
import { generateToken } from '../shared/random/random.services';
import { API_KEY_PREFIX, API_KEY_TOKEN_LENGTH } from './api-keys.constants';
export function generateApiToken() {
const { token } = generateToken({ length: API_KEY_TOKEN_LENGTH });
return { token: `${API_KEY_PREFIX}_${token}` };
}

View File

@@ -0,0 +1,42 @@
import type { ApiKeyPermissions } from './api-keys.types';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { organizationMembersTable } from '../organizations/organizations.table';
import { createPrimaryKeyField, createTimestampColumns } from '../shared/db/columns.helpers';
import { usersTable } from '../users/users.table';
export const apiKeysTable = sqliteTable(
'api_keys',
{
...createPrimaryKeyField({ prefix: 'ak' }),
...createTimestampColumns(),
name: text('name').notNull(),
keyHash: text('key_hash').notNull().unique(),
// the prefix is used to identify the key, it is the
prefix: text('prefix').notNull(),
userId: text('user_id')
.notNull()
.references(() => usersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
lastUsedAt: integer('last_used_at', { mode: 'timestamp_ms' }),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
permissions: text('permissions', { mode: 'json' }).notNull().$type<ApiKeyPermissions[]>().default([]),
allOrganizations: integer('all_organizations', { mode: 'boolean' }).notNull().default(false),
},
table => [
// To get an API key by its token
index('key_hash_index').on(table.keyHash),
],
);
// We use an intermediate table (instead of a json array) to link API keys to organization members, so the relationship between
// the api key and the organization is deleted on cascade when the organization is deleted or the member is removed from the organization.
export const apiKeyOrganizationsTable = sqliteTable('api_key_organizations', {
apiKeyId: text('api_key_id')
.notNull()
.references(() => apiKeysTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
organizationMemberId: text('organization_member_id')
.notNull()
.references(() => organizationMembersTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
});

View File

@@ -0,0 +1,6 @@
import type { API_KEY_PERMISSIONS_VALUES } from './api-keys.constants';
import type { apiKeysTable } from './api-keys.tables';
export type ApiKeyPermissions = (typeof API_KEY_PERMISSIONS_VALUES)[number];
export type ApiKey = typeof apiKeysTable.$inferSelect;

View File

@@ -0,0 +1,54 @@
import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from '../app/database/database.test-utils';
import { createApiKeysRepository } from './api-keys.repository';
import { createApiKey, getApiKey } from './api-keys.usecases';
describe('api-keys usecases', () => {
describe('createApiKey', () => {
test('the api key is created with the correct hash', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'user_1', email: 'test@test.com' },
],
});
const apiKeyRepository = createApiKeysRepository({ db });
const { apiKey, token } = await createApiKey({
name: 'test',
userId: 'user_1',
permissions: ['documents:create'],
organizationIds: [],
allOrganizations: false,
apiKeyRepository,
generateApiToken: () => ({ token: 'ppapi_HT2Hj5V8A3WHMQtVcMDB9UucqUxPU15o1aI6qOc1Oy5qBvbSEr4jZzsjuFYPqCP0' }),
});
expect(apiKey).to.deep.include({
name: 'test',
permissions: ['documents:create'],
keyHash: 'ExkPP3tmeg55u7ObhGuMOywnfkbLVGYE2VBxMj8koB4',
userId: 'user_1',
prefix: 'ppapi_HT2Hj',
});
expect(token).to.eql('ppapi_HT2Hj5V8A3WHMQtVcMDB9UucqUxPU15o1aI6qOc1Oy5qBvbSEr4jZzsjuFYPqCP0');
});
});
describe('getApiKey', () => {
test('an api key can be retrieved by its token', async () => {
const { db } = await createInMemoryDatabase({
users: [
{ id: 'user_1', email: 'test@test.com' },
],
apiKeys: [
{ id: 'api_key_1', keyHash: 'ExkPP3tmeg55u7ObhGuMOywnfkbLVGYE2VBxMj8koB4', userId: 'user_1', prefix: 'ppapi_HT2Hj', name: 'test' },
],
});
const apiKeyRepository = createApiKeysRepository({ db });
const { apiKey } = await getApiKey({ token: 'ppapi_HT2Hj5V8A3WHMQtVcMDB9UucqUxPU15o1aI6qOc1Oy5qBvbSEr4jZzsjuFYPqCP0', apiKeyRepository });
expect(apiKey.id).to.eql('api_key_1');
});
});
});

View File

@@ -0,0 +1,53 @@
import type { ApiKeysRepository } from './api-keys.repository';
import type { ApiKeyPermissions } from './api-keys.types';
import { getApiKeyHash, getApiKeyUiPrefix } from './api-keys.models';
import { generateApiToken as generateApiTokenImpl } from './api-keys.services';
export async function createApiKey({
name,
userId,
permissions,
organizationIds,
allOrganizations,
expiresAt,
apiKeyRepository,
generateApiToken = generateApiTokenImpl,
}: {
name: string;
userId: string;
permissions: ApiKeyPermissions[];
organizationIds: string[];
allOrganizations: boolean;
expiresAt?: Date;
apiKeyRepository: ApiKeysRepository;
generateApiToken?: () => { token: string };
}) {
const { token } = generateApiToken();
const { prefix } = getApiKeyUiPrefix({ token });
const { keyHash } = getApiKeyHash({ token });
const { apiKey } = await apiKeyRepository.saveApiKey({
name,
permissions,
keyHash,
organizationIds,
allOrganizations,
expiresAt,
userId,
prefix,
});
return {
apiKey,
token,
};
}
export async function getApiKey({ token, apiKeyRepository }: { token: string; apiKeyRepository: ApiKeysRepository }) {
const { keyHash } = getApiKeyHash({ token });
const apiKey = await apiKeyRepository.getApiKeyByHash({ keyHash });
return apiKey;
}

View File

@@ -0,0 +1,88 @@
import type { Document } from '../../documents/documents.types';
import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from '../../app/database/database.test-utils';
import { createServer } from '../../app/server';
import { overrideConfig } from '../../config/config.test-utils';
import { ORGANIZATION_ROLES } from '../../organizations/organizations.constants';
describe('api-key e2e', () => {
test('one can create a document using an api key', async () => {
const { db } = await createInMemoryDatabase({
users: [{ id: 'usr_111111111111111111111111', email: 'user@example.com' }],
organizations: [{ id: 'org_222222222222222222222222', name: 'Org 1' }],
organizationMembers: [{ organizationId: 'org_222222222222222222222222', userId: 'usr_111111111111111111111111', role: ORGANIZATION_ROLES.OWNER }],
});
const { app } = await createServer({
db,
config: overrideConfig({
env: 'test',
documentsStorage: {
driver: 'in-memory',
},
}),
});
const createApiKeyResponse = await app.request(
'/api/api-keys',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Test API Key',
permissions: ['documents:create'],
}),
},
{ loggedInUserId: 'usr_111111111111111111111111' },
);
expect(createApiKeyResponse.status).toBe(200);
const { token } = await createApiKeyResponse.json() as { token: string };
const formData = new FormData();
formData.append('file', new File(['test'], 'invoice.txt', { type: 'text/plain' }));
const bodyResponse = new Response(formData);
const createDocumentResponse = await app.request('/api/organizations/org_222222222222222222222222/documents', {
method: 'POST',
headers: {
...Object.fromEntries(bodyResponse.headers.entries()),
Authorization: `Bearer ${token}`,
},
body: await bodyResponse.arrayBuffer(),
});
expect(createDocumentResponse.status).toBe(200);
const { document } = await createDocumentResponse.json() as { document: Document };
expect(document).to.deep.include({
isDeleted: false,
deletedAt: null,
organizationId: 'org_222222222222222222222222',
createdBy: 'usr_111111111111111111111111',
deletedBy: null,
originalName: 'invoice.txt',
originalSize: 4,
originalSha256Hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
name: 'invoice.txt',
mimeType: 'text/plain',
content: 'test',
});
const fetchDocumentResponse = await app.request(`/api/organizations/org_222222222222222222222222/documents/${document.id}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
// The api key is not authorized to read the document, only documents:create is granted
expect(fetchDocumentResponse.status).toBe(401);
expect(await fetchDocumentResponse.json()).to.eql({
error: {
code: 'auth.unauthorized',
message: 'Unauthorized',
},
});
});
});

View File

@@ -0,0 +1,22 @@
import type { ApiKeyPermissions } from '../../api-keys/api-keys.types';
import type { Context } from '../server.types';
import { createMiddleware } from 'hono/factory';
import { createUnauthorizedError } from './auth.errors';
import { isAuthenticationValid } from './auth.models';
export function requireAuthentication({ apiKeyPermissions }: { apiKeyPermissions?: ApiKeyPermissions[] } = {}) {
return createMiddleware(async (context: Context, next) => {
const isAuthenticated = isAuthenticationValid({
authType: context.get('authType'),
session: context.get('session'),
apiKey: context.get('apiKey'),
requiredApiKeyPermissions: apiKeyPermissions,
});
if (!isAuthenticated) {
throw createUnauthorizedError();
}
await next();
});
}

View File

@@ -1,6 +1,8 @@
import type { ApiKey, ApiKeyPermissions } from '../../api-keys/api-keys.types';
import type { Config } from '../../config/config.types';
import type { Session } from './auth.types';
import { describe, expect, test } from 'vitest';
import { getTrustedOrigins } from './auth.models';
import { getTrustedOrigins, isAuthenticationValid } from './auth.models';
describe('auth models', () => {
describe('getTrustedOrigins', () => {
@@ -45,4 +47,108 @@ describe('auth models', () => {
]);
});
});
describe('checkAuthentication', () => {
describe('coherence checks', () => {
test('when the auth type is null, the authentication is invalid', () => {
expect(isAuthenticationValid({
authType: null,
})).to.eql(false);
});
test('when the auth type is api-key, the apiKey is required', () => {
expect(isAuthenticationValid({
authType: 'api-key',
apiKey: null,
session: null,
})).to.eql(false);
});
test('when the auth type is session, the session is required', () => {
expect(isAuthenticationValid({
authType: 'session',
apiKey: null,
session: null,
})).to.eql(false);
});
test('when the auth type is api-key, the session is not allowed', () => {
expect(isAuthenticationValid({
authType: 'api-key',
apiKey: {} as ApiKey,
session: {} as Session,
})).to.eql(false);
});
test('when the auth type is session, the apiKey is not allowed', () => {
expect(isAuthenticationValid({
authType: 'session',
apiKey: {} as ApiKey,
session: {} as Session,
})).to.eql(false);
});
test('when the auth type is api-key, the requiredApiKeyPermissions are required', () => {
expect(isAuthenticationValid({
authType: 'api-key',
apiKey: {} as ApiKey,
session: null,
})).to.eql(false);
});
test('when both the apiKey and the session are provided, the authentication is invalid', () => {
expect(isAuthenticationValid({
authType: 'api-key',
apiKey: {} as ApiKey,
session: {} as Session,
})).to.eql(false);
});
});
test('when the auth type is api-key, at least one permission must match', () => {
expect(isAuthenticationValid({
authType: 'api-key',
apiKey: {
permissions: [] as ApiKeyPermissions[],
} as ApiKey,
requiredApiKeyPermissions: ['documents:create'],
})).to.eql(false);
expect(isAuthenticationValid({
authType: 'api-key',
apiKey: {
permissions: ['documents:create'],
} as ApiKey,
requiredApiKeyPermissions: ['documents:create'],
})).to.eql(true);
expect(isAuthenticationValid({
authType: 'api-key',
apiKey: {
permissions: ['documents:create'],
} as ApiKey,
requiredApiKeyPermissions: ['documents:read'],
})).to.eql(false);
expect(isAuthenticationValid({
authType: 'api-key',
apiKey: {
permissions: ['documents:create'],
} as ApiKey,
requiredApiKeyPermissions: ['documents:create', 'documents:read'],
})).to.eql(true);
});
test('when the auth type is session, the session should exist', () => {
expect(isAuthenticationValid({
authType: 'session',
session: null,
})).to.eql(false);
expect(isAuthenticationValid({
authType: 'session',
session: {} as Session,
})).to.eql(true);
});
});
});

View File

@@ -1,12 +1,16 @@
import type { ApiKey, ApiKeyPermissions } from '../../api-keys/api-keys.types';
import type { Config } from '../../config/config.types';
import type { Context } from '../server.types';
import type { Session } from './auth.types';
import { uniq } from 'lodash-es';
import { createError } from '../../shared/errors/errors';
export function getUser({ context }: { context: Context }) {
const user = context.get('user');
const userId = context.get('userId');
if (!user) {
if (!userId) {
// This should never happen as getUser is called in authenticated routes
// just for proper type safety
throw createError({
message: 'User not found in context',
code: 'users.not_found',
@@ -16,8 +20,7 @@ export function getUser({ context }: { context: Context }) {
}
return {
user,
userId: user.id,
userId,
};
}
@@ -35,3 +38,47 @@ export function getTrustedOrigins({ config }: { config: Config }) {
trustedOrigins: uniq([baseUrl, ...trustedOrigins]),
};
}
export function isAuthenticationValid({
session,
apiKey,
requiredApiKeyPermissions,
authType,
}: {
session?: Session | null | undefined;
apiKey?: ApiKey | null | undefined;
requiredApiKeyPermissions?: ApiKeyPermissions[];
authType: 'api-key' | 'session' | null;
}): boolean {
if (!authType) {
return false;
}
if (session && authType !== 'session') {
return false;
}
if (apiKey && authType !== 'api-key') {
return false;
}
if (authType === 'api-key') {
if (!apiKey) {
return false;
}
if (!requiredApiKeyPermissions) {
return false;
}
const atLeastOnePermissionMatches = apiKey.permissions.some(permission => requiredApiKeyPermissions.includes(permission));
return atLeastOnePermissionMatches;
}
if (authType === 'session' && session) {
return true;
}
return false;
}

View File

@@ -1,23 +1,37 @@
import type { RouteDefinitionContext } from '../server.types';
import type { Context, RouteDefinitionContext } from '../server.types';
import type { Session } from './auth.types';
import { get } from 'lodash-es';
export function registerAuthRoutes({ app, auth }: RouteDefinitionContext) {
export function registerAuthRoutes({ app, auth, config }: RouteDefinitionContext) {
app.on(
['POST', 'GET'],
'/api/auth/*',
context => auth.handler(context.req.raw),
);
app.use('*', async (context, next) => {
app.use('*', async (context: Context, next) => {
const session = await auth.api.getSession({ headers: context.req.raw.headers });
if (!session) {
context.set('user', null);
context.set('session', null);
return next();
if (session) {
context.set('userId', session.user.id);
context.set('session', session.session);
context.set('authType', 'session');
}
context.set('user', session.user);
context.set('session', session.session);
return next();
});
if (config.env === 'test') {
app.use('*', async (context: Context, next) => {
const overrideUserId = get(context.env, 'loggedInUserId') as string | undefined;
if (overrideUserId) {
context.set('userId', overrideUserId);
context.set('session', {} as Session);
context.set('authType', 'session');
}
return next();
});
}
}

View File

@@ -0,0 +1,3 @@
import type { Auth } from './auth.services';
export type Session = Auth['$Infer']['Session']['session'];

View File

@@ -1,4 +1,5 @@
import type { Database } from './database.types';
import { apiKeyOrganizationsTable, apiKeysTable } from '../../api-keys/api-keys.tables';
import { documentsTable } from '../../documents/documents.table';
import { intakeEmailsTable } from '../../intake-emails/intake-emails.tables';
import { organizationMembersTable, organizationsTable } from '../../organizations/organizations.table';
@@ -35,6 +36,8 @@ const seedTables = {
taggingRules: taggingRulesTable,
taggingRuleConditions: taggingRuleConditionsTable,
taggingRuleActions: taggingRuleActionsTable,
apiKeys: apiKeysTable,
apiKeyOrganizations: apiKeyOrganizationsTable,
} as const;
type SeedTablesRows = {

View File

@@ -0,0 +1,69 @@
import { inspectRoutes } from 'hono/dev';
import { describe, expect, test } from 'vitest';
import { createInMemoryDatabase } from './database/database.test-utils';
import { createServer } from './server';
function setValidParams(path: string) {
const newPath = path
.replaceAll(':organizationId', 'org_111111111111111111111111')
.replaceAll(':userId', 'user_222222222222222222222222')
.replaceAll(':documentId', 'doc_333333333333333333333333')
.replaceAll(':tagId', 'tag_444444444444444444444444')
.replaceAll(':taggingRuleId', 'rule_555555555555555555555555')
.replaceAll(':intakeEmailId', 'email_666666666666666666666666')
.replaceAll(':apiKeyId', 'api_key_777777777777777777777777');
// throw if there are any remaining params
if (newPath.match(/:(\w+)/g)) {
throw new Error(`Add a dummy value for the params in ${path}`);
}
return newPath;
}
describe('server routes', () => {
test('all routes should respond with a 401 when non-authenticated, except for public and auth-related routes', async () => {
const { db } = await createInMemoryDatabase();
const { app } = await createServer({ db });
const publicRoutes = [
'GET /api/ping',
'GET /api/health',
'GET /api/config',
// Authentication is handled by payload signature verification
'POST /api/intake-emails/ingest',
// Stripe stuff
'POST /api/stripe/webhook',
];
// Excluding auth routes that are managed by better-auth
const authRoutesPrefix = '/api/auth';
const routes = inspectRoutes(app)
.filter(route => !route.isMiddleware)
.filter(route => !publicRoutes.includes(`${route.method} ${route.path}`))
.filter(route => !route.path.startsWith(authRoutesPrefix));
for (const route of routes) {
const response = await app.request(
setValidParams(route.path),
{
method: route.method,
},
);
expect(response.status).to.eql(
401,
`Route ${route.method} ${route.path} did not return 401 when not authenticated`,
);
expect(await response.json()).to.eql({
error: {
message: 'Unauthorized',
code: 'auth.unauthorized',
},
});
}
});
});

View File

@@ -1,47 +1,26 @@
import type { RouteDefinitionContext } from './server.types';
import { registerConfigPublicRoutes } from '../config/config.routes';
import { registerDocumentsPrivateRoutes } from '../documents/documents.routes';
import { registerIntakeEmailsPrivateRoutes, registerIntakeEmailsPublicRoutes } from '../intake-emails/intake-emails.routes';
import { registerOrganizationsPrivateRoutes } from '../organizations/organizations.routes';
import { registerSubscriptionsPrivateRoutes, registerSubscriptionsPublicRoutes } from '../subscriptions/subscriptions.routes';
import { registerApiKeysRoutes } from '../api-keys/api-keys.routes';
import { registerConfigRoutes } from '../config/config.routes';
import { registerDocumentsRoutes } from '../documents/documents.routes';
import { registerIntakeEmailsRoutes } from '../intake-emails/intake-emails.routes';
import { registerOrganizationsRoutes } from '../organizations/organizations.routes';
import { registerSubscriptionsRoutes } from '../subscriptions/subscriptions.routes';
import { registerTaggingRulesRoutes } from '../tagging-rules/tagging-rules.routes';
import { registerTagsRoutes } from '../tags/tags.routes';
import { registerUsersPrivateRoutes } from '../users/users.routes';
import { createUnauthorizedError } from './auth/auth.errors';
import { getSession } from './auth/auth.models';
import { registerUsersRoutes } from '../users/users.routes';
import { registerAuthRoutes } from './auth/auth.routes';
import { registerHealthCheckRoutes } from './health-check/health-check.routes';
export function registerRoutes(context: RouteDefinitionContext) {
registerAuthRoutes(context);
registerPublicRoutes(context);
registerPrivateRoutes(context);
}
function registerPublicRoutes(context: RouteDefinitionContext) {
registerConfigPublicRoutes(context);
registerConfigRoutes(context);
registerHealthCheckRoutes(context);
registerIntakeEmailsPublicRoutes(context);
registerSubscriptionsPublicRoutes(context);
}
function registerPrivateRoutes(context: RouteDefinitionContext) {
context.app.use(async (handlerContext, next) => {
const { session } = getSession({ context: handlerContext });
if (!session) {
throw createUnauthorizedError();
}
await next();
});
registerUsersPrivateRoutes(context);
registerOrganizationsPrivateRoutes(context);
registerDocumentsPrivateRoutes(context);
registerIntakeEmailsRoutes(context);
registerSubscriptionsRoutes(context);
registerUsersRoutes(context);
registerOrganizationsRoutes(context);
registerDocumentsRoutes(context);
registerTagsRoutes(context);
registerIntakeEmailsPrivateRoutes(context);
registerSubscriptionsPrivateRoutes(context);
registerTaggingRulesRoutes(context);
registerApiKeysRoutes(context);
}

View File

@@ -1,6 +1,7 @@
import type { GlobalDependencies, ServerInstanceGenerics } from './server.types';
import { Hono } from 'hono';
import { secureHeaders } from 'hono/secure-headers';
import { createApiKeyMiddleware } from '../api-keys/api-keys.middlewares';
import { parseConfig } from '../config/config';
import { createEmailsServices } from '../emails/emails.services';
import { createLoggerMiddleware } from '../shared/logger/logger.middleware';
@@ -33,9 +34,9 @@ async function createGlobalDependencies(partialDeps: Partial<GlobalDependencies>
};
}
export async function createServer(initialDeps: Partial<GlobalDependencies>) {
export async function createServer(initialDeps: Partial<GlobalDependencies> = {}) {
const dependencies = await createGlobalDependencies(initialDeps);
const { config, trackingServices } = dependencies;
const { config, trackingServices, db } = dependencies;
const app = new Hono<ServerInstanceGenerics>({ strict: true });
@@ -47,6 +48,8 @@ export async function createServer(initialDeps: Partial<GlobalDependencies>) {
registerErrorMiddleware({ app });
registerStaticAssetsRoutes({ app, config });
app.use(createApiKeyMiddleware({ db }));
registerRoutes({ app, ...dependencies });
return {

View File

@@ -1,15 +1,19 @@
import type { Context as BaseContext, Hono } from 'hono';
import type { ApiKey } from '../api-keys/api-keys.types';
import type { Config } from '../config/config.types';
import type { EmailsServices } from '../emails/emails.services';
import type { SubscriptionsServices } from '../subscriptions/subscriptions.services';
import type { TrackingServices } from '../tracking/tracking.services';
import type { Auth } from './auth/auth.services';
import type { Session } from './auth/auth.types';
import type { Database } from './database/database.types';
export type ServerInstanceGenerics = {
Variables: {
user: Auth['$Infer']['Session']['user'] | null;
session: Auth['$Infer']['Session']['session'] | null;
userId: string | null;
session: Session | null;
apiKey: ApiKey | null;
authType: 'session' | 'api-key' | null;
};
};

View File

@@ -1,7 +1,7 @@
import type { RouteDefinitionContext } from '../app/server.types';
import { getPublicConfig } from './config.models';
export async function registerConfigPublicRoutes(context: RouteDefinitionContext) {
export async function registerConfigRoutes(context: RouteDefinitionContext) {
setupGetPublicConfigRoute(context);
}

View File

@@ -1,6 +1,7 @@
import type { RouteDefinitionContext } from '../app/server.types';
import { bodyLimit } from 'hono/body-limit';
import { z } from 'zod';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { organizationIdSchema } from '../organizations/organization.schemas';
import { createOrganizationsRepository } from '../organizations/organizations.repository';
@@ -18,7 +19,7 @@ import { documentIdSchema } from './documents.schemas';
import { createDocument, deleteAllTrashDocuments, deleteTrashDocument, ensureDocumentExists, getDocumentOrThrow } from './documents.usecases';
import { createDocumentStorageService } from './storage/documents.storage.services';
export function registerDocumentsPrivateRoutes(context: RouteDefinitionContext) {
export function registerDocumentsRoutes(context: RouteDefinitionContext) {
setupCreateDocumentRoute(context);
setupGetDocumentsRoute(context);
setupSearchDocumentsRoute(context);
@@ -36,6 +37,7 @@ export function registerDocumentsPrivateRoutes(context: RouteDefinitionContext)
function setupCreateDocumentRoute({ app, config, db, trackingServices }: RouteDefinitionContext) {
app.post(
'/api/organizations/:organizationId/documents',
requireAuthentication({ apiKeyPermissions: ['documents:create'] }),
(context, next) => {
const { maxUploadSize } = config.documentsStorage;
@@ -115,6 +117,7 @@ function setupCreateDocumentRoute({ app, config, db, trackingServices }: RouteDe
function setupGetDocumentsRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/documents',
requireAuthentication({ apiKeyPermissions: ['documents:read'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -158,6 +161,7 @@ function setupGetDocumentsRoute({ app, db }: RouteDefinitionContext) {
function setupGetDeletedDocumentsRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/documents/deleted',
requireAuthentication({ apiKeyPermissions: ['documents:read'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -197,6 +201,7 @@ function setupGetDeletedDocumentsRoute({ app, db }: RouteDefinitionContext) {
function setupGetDocumentRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/documents/:documentId',
requireAuthentication({ apiKeyPermissions: ['documents:read'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
documentId: documentIdSchema,
@@ -223,6 +228,7 @@ function setupGetDocumentRoute({ app, db }: RouteDefinitionContext) {
function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) {
app.delete(
'/api/organizations/:organizationId/documents/:documentId',
requireAuthentication({ apiKeyPermissions: ['documents:delete'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
documentId: documentIdSchema,
@@ -250,6 +256,7 @@ function setupDeleteDocumentRoute({ app, db }: RouteDefinitionContext) {
function setupRestoreDocumentRoute({ app, db }: RouteDefinitionContext) {
app.post(
'/api/organizations/:organizationId/documents/:documentId/restore',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
documentId: documentIdSchema,
@@ -280,6 +287,7 @@ function setupRestoreDocumentRoute({ app, db }: RouteDefinitionContext) {
function setupGetDocumentFileRoute({ app, config, db }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/documents/:documentId/file',
requireAuthentication({ apiKeyPermissions: ['documents:read'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
documentId: documentIdSchema,
@@ -316,6 +324,7 @@ function setupGetDocumentFileRoute({ app, config, db }: RouteDefinitionContext)
function setupSearchDocumentsRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/documents/search',
requireAuthentication({ apiKeyPermissions: ['documents:read'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -349,6 +358,7 @@ function setupSearchDocumentsRoute({ app, db }: RouteDefinitionContext) {
function setupGetOrganizationDocumentsStatsRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/documents/statistics',
requireAuthentication({ apiKeyPermissions: ['documents:read'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -377,6 +387,7 @@ function setupGetOrganizationDocumentsStatsRoute({ app, db }: RouteDefinitionCon
function setupDeleteTrashDocumentRoute({ app, config, db }: RouteDefinitionContext) {
app.delete(
'/api/organizations/:organizationId/documents/trash/:documentId',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
documentId: documentIdSchema,
@@ -404,6 +415,7 @@ function setupDeleteTrashDocumentRoute({ app, config, db }: RouteDefinitionConte
function setupDeleteAllTrashDocumentsRoute({ app, config, db }: RouteDefinitionContext) {
app.delete(
'/api/organizations/:organizationId/documents/trash',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -428,6 +440,7 @@ function setupDeleteAllTrashDocumentsRoute({ app, config, db }: RouteDefinitionC
function setupUpdateDocumentRoute({ app, db }: RouteDefinitionContext) {
app.patch(
'/api/organizations/:organizationId/documents/:documentId',
requireAuthentication({ apiKeyPermissions: ['documents:update'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
documentId: documentIdSchema,

View File

@@ -2,6 +2,7 @@ import type { RouteDefinitionContext } from '../app/server.types';
import { verifySignature } from '@owlrelay/webhook';
import { z } from 'zod';
import { createUnauthorizedError } from '../app/auth/auth.errors';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { createDocumentsRepository } from '../documents/documents.repository';
import { createDocumentStorageService } from '../documents/storage/documents.storage.services';
@@ -24,20 +25,18 @@ import { createIntakeEmail, deleteIntakeEmail, processIntakeEmailIngestion } fro
const logger = createLogger({ namespace: 'intake-emails.routes' });
export function registerIntakeEmailsPrivateRoutes(context: RouteDefinitionContext) {
export function registerIntakeEmailsRoutes(context: RouteDefinitionContext) {
setupIngestIntakeEmailRoute(context);
setupGetOrganizationIntakeEmailsRoute(context);
setupCreateIntakeEmailRoute(context);
setupDeleteIntakeEmailRoute(context);
setupUpdateIntakeEmailRoute(context);
}
export function registerIntakeEmailsPublicRoutes(context: RouteDefinitionContext) {
setupIngestIntakeEmailRoute(context);
}
function setupGetOrganizationIntakeEmailsRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/intake-emails',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -60,6 +59,7 @@ function setupGetOrganizationIntakeEmailsRoute({ app, db }: RouteDefinitionConte
function setupCreateIntakeEmailRoute({ app, db, config }: RouteDefinitionContext) {
app.post(
'/api/organizations/:organizationId/intake-emails',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -91,6 +91,7 @@ function setupCreateIntakeEmailRoute({ app, db, config }: RouteDefinitionContext
function setupDeleteIntakeEmailRoute({ app, db, config }: RouteDefinitionContext) {
app.delete(
'/api/organizations/:organizationId/intake-emails/:intakeEmailId',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
intakeEmailId: intakeEmailIdSchema,
@@ -115,6 +116,7 @@ function setupDeleteIntakeEmailRoute({ app, db, config }: RouteDefinitionContext
function setupUpdateIntakeEmailRoute({ app, db }: RouteDefinitionContext) {
app.put(
'/api/organizations/:organizationId/intake-emails/:intakeEmailId',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
intakeEmailId: intakeEmailIdSchema,

View File

@@ -1,5 +1,6 @@
import type { RouteDefinitionContext } from '../app/server.types';
import { z } from 'zod';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { validateJsonBody, validateParams } from '../shared/validation/validation';
import { createUsersRepository } from '../users/users.repository';
@@ -7,7 +8,7 @@ import { organizationIdSchema } from './organization.schemas';
import { createOrganizationsRepository } from './organizations.repository';
import { checkIfUserCanCreateNewOrganization, createOrganization, ensureUserIsInOrganization } from './organizations.usecases';
export async function registerOrganizationsPrivateRoutes(context: RouteDefinitionContext) {
export async function registerOrganizationsRoutes(context: RouteDefinitionContext) {
setupGetOrganizationsRoute(context);
setupCreateOrganizationRoute(context);
setupGetOrganizationRoute(context);
@@ -16,22 +17,27 @@ export async function registerOrganizationsPrivateRoutes(context: RouteDefinitio
}
function setupGetOrganizationsRoute({ app, db }: RouteDefinitionContext) {
app.get('/api/organizations', async (context) => {
const { userId } = getUser({ context });
app.get(
'/api/organizations',
requireAuthentication(),
async (context) => {
const { userId } = getUser({ context });
const organizationsRepository = createOrganizationsRepository({ db });
const organizationsRepository = createOrganizationsRepository({ db });
const { organizations } = await organizationsRepository.getUserOrganizations({ userId });
const { organizations } = await organizationsRepository.getUserOrganizations({ userId });
return context.json({
organizations,
});
});
return context.json({
organizations,
});
},
);
}
function setupCreateOrganizationRoute({ app, db, config }: RouteDefinitionContext) {
app.post(
'/api/organizations',
requireAuthentication(),
validateJsonBody(z.object({
name: z.string().min(3).max(50),
})),
@@ -56,6 +62,7 @@ function setupCreateOrganizationRoute({ app, db, config }: RouteDefinitionContex
function setupGetOrganizationRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -77,6 +84,7 @@ function setupGetOrganizationRoute({ app, db }: RouteDefinitionContext) {
function setupUpdateOrganizationRoute({ app, db }: RouteDefinitionContext) {
app.put(
'/api/organizations/:organizationId',
requireAuthentication(),
validateJsonBody(z.object({
name: z.string().min(3).max(50),
})),
@@ -104,6 +112,7 @@ function setupUpdateOrganizationRoute({ app, db }: RouteDefinitionContext) {
function setupDeleteOrganizationRoute({ app, db }: RouteDefinitionContext) {
app.delete(
'/api/organizations/:organizationId',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
})),

View File

@@ -0,0 +1,20 @@
import { describe, expect, test } from 'vitest';
import { sha256 } from './hash';
describe('hash', () => {
describe('sha256', () => {
test('hashes a string using sha256', () => {
expect(sha256('test')).to.eql('9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08');
});
test('the output format can be specified, default is hex', () => {
expect(sha256('test', { digest: 'base64' })).to.eql('n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=');
expect(sha256('test', { digest: 'base64url' })).to.eql('n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg');
expect(sha256('test', { digest: 'hex' })).to.eql('9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08');
expect(sha256('test', { })).to.eql('9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08');
expect(sha256('test', undefined)).to.eql('9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08');
expect(sha256('test')).to.eql('9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08');
});
});
});

View File

@@ -0,0 +1,5 @@
import { createHash } from 'node:crypto';
export function sha256(value: string, { digest = 'hex' }: { digest?: 'hex' | 'base64' | 'base64url' } = {}) {
return createHash('sha256').update(value).digest(digest);
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, test } from 'vitest';
import { generateToken } from './random.services';
describe('random', () => {
describe('generateToken', () => {
test('create random token of 32 characters by default', () => {
const { token } = generateToken();
expect(token.length).toBe(32);
});
test('the length can be customized', () => {
expect(generateToken({ length: 64 }).token.length).toBe(64);
expect(generateToken({ length: 31 }).token.length).toBe(31);
});
});
});

View File

@@ -0,0 +1,10 @@
import { customAlphabet } from 'nanoid';
const corpus = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const nanoid = customAlphabet(corpus);
export function generateToken({ length = 32 }: { length?: number } = {}) {
const token = nanoid(length);
return { token };
}

View File

@@ -1,6 +1,7 @@
import type { RouteDefinitionContext } from '../app/server.types';
import { get, pick } from 'lodash-es';
import { z } from 'zod';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { organizationIdSchema } from '../organizations/organization.schemas';
import { createOrganizationNotFoundError } from '../organizations/organizations.errors';
@@ -19,16 +20,13 @@ import { handleStripeWebhookEvent } from './subscriptions.usecases';
const logger = createLogger({ namespace: 'subscriptions.routes' });
export function registerSubscriptionsPrivateRoutes(context: RouteDefinitionContext) {
export function registerSubscriptionsRoutes(context: RouteDefinitionContext) {
setupStripeWebhookRoute(context);
setupCreateCheckoutSessionRoute(context);
setupGetCustomerPortalRoute(context);
getOrganizationSubscriptionRoute(context);
}
export function registerSubscriptionsPublicRoutes(context: RouteDefinitionContext) {
setupStripeWebhookRoute(context);
}
function setupStripeWebhookRoute({ app, config, db, subscriptionsServices }: RouteDefinitionContext) {
app.post('/api/stripe/webhook', async (context) => {
const signature = getHeader({ context, name: 'stripe-signature' });
@@ -64,6 +62,7 @@ function setupStripeWebhookRoute({ app, config, db, subscriptionsServices }: Rou
async function setupCreateCheckoutSessionRoute({ app, config, db, subscriptionsServices }: RouteDefinitionContext) {
app.post(
'/api/organizations/:organizationId/checkout-session',
requireAuthentication(),
validateJsonBody(z.object({
planId: z.enum([PLUS_PLAN_ID]),
})),
@@ -128,6 +127,7 @@ async function setupCreateCheckoutSessionRoute({ app, config, db, subscriptionsS
function setupGetCustomerPortalRoute({ app, db, subscriptionsServices }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/customer-portal',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -155,6 +155,7 @@ function setupGetCustomerPortalRoute({ app, db, subscriptionsServices }: RouteDe
function getOrganizationSubscriptionRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/subscription',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
})),

View File

@@ -1,6 +1,7 @@
import type { RouteDefinitionContext } from '../app/server.types';
import type { TaggingRuleField, TaggingRuleOperator } from './tagging-rules.types';
import { z } from 'zod';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { organizationIdSchema } from '../organizations/organization.schemas';
import { createOrganizationsRepository } from '../organizations/organizations.repository';
@@ -23,6 +24,7 @@ export function registerTaggingRulesRoutes(context: RouteDefinitionContext) {
function setupGetOrganizationTaggingRulesRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/tagging-rules',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -48,6 +50,7 @@ function setupGetOrganizationTaggingRulesRoute({ app, db }: RouteDefinitionConte
function setupCreateTaggingRuleRoute({ app, db }: RouteDefinitionContext) {
app.post(
'/api/organizations/:organizationId/tagging-rules',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -83,6 +86,7 @@ function setupCreateTaggingRuleRoute({ app, db }: RouteDefinitionContext) {
function setupDeleteTaggingRuleRoute({ app, db }: RouteDefinitionContext) {
app.delete(
'/api/organizations/:organizationId/tagging-rules/:taggingRuleId',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
taggingRuleId: taggingRuleIdSchema,
@@ -107,6 +111,7 @@ function setupDeleteTaggingRuleRoute({ app, db }: RouteDefinitionContext) {
function setupGetTaggingRuleRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/tagging-rules/:taggingRuleId',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
taggingRuleId: taggingRuleIdSchema,
@@ -133,6 +138,7 @@ function setupGetTaggingRuleRoute({ app, db }: RouteDefinitionContext) {
function setupUpdateTaggingRuleRoute({ app, db }: RouteDefinitionContext) {
app.put(
'/api/organizations/:organizationId/tagging-rules/:taggingRuleId',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
taggingRuleId: taggingRuleIdSchema,

View File

@@ -1,5 +1,6 @@
import type { RouteDefinitionContext } from '../app/server.types';
import { z } from 'zod';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { documentIdSchema } from '../documents/documents.schemas';
import { organizationIdSchema } from '../organizations/organization.schemas';
@@ -22,7 +23,7 @@ export function registerTagsRoutes(context: RouteDefinitionContext) {
function setupCreateNewTagRoute({ app, db }: RouteDefinitionContext) {
app.post(
'/api/organizations/:organizationId/tags',
requireAuthentication({ apiKeyPermissions: ['tags:create'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -56,7 +57,7 @@ function setupCreateNewTagRoute({ app, db }: RouteDefinitionContext) {
function setupGetOrganizationTagsRoute({ app, db }: RouteDefinitionContext) {
app.get(
'/api/organizations/:organizationId/tags',
requireAuthentication({ apiKeyPermissions: ['tags:read'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
})),
@@ -78,7 +79,7 @@ function setupGetOrganizationTagsRoute({ app, db }: RouteDefinitionContext) {
function setupUpdateTagRoute({ app, db }: RouteDefinitionContext) {
app.put(
'/api/organizations/:organizationId/tags/:tagId',
requireAuthentication({ apiKeyPermissions: ['tags:update'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
tagId: tagIdSchema,
@@ -113,7 +114,7 @@ function setupUpdateTagRoute({ app, db }: RouteDefinitionContext) {
function setupDeleteTagRoute({ app, db }: RouteDefinitionContext) {
app.delete(
'/api/organizations/:organizationId/tags/:tagId',
requireAuthentication({ apiKeyPermissions: ['tags:delete'] }),
validateParams(z.object({
organizationId: organizationIdSchema,
tagId: tagIdSchema,
@@ -139,7 +140,7 @@ function setupDeleteTagRoute({ app, db }: RouteDefinitionContext) {
function setupAddTagToDocumentRoute({ app, db }: RouteDefinitionContext) {
app.post(
'/api/organizations/:organizationId/documents/:documentId/tags',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
documentId: documentIdSchema,
@@ -170,7 +171,7 @@ function setupAddTagToDocumentRoute({ app, db }: RouteDefinitionContext) {
function setupRemoveTagFromDocumentRoute({ app, db }: RouteDefinitionContext) {
app.delete(
'/api/organizations/:organizationId/documents/:documentId/tags/:tagId',
requireAuthentication(),
validateParams(z.object({
organizationId: organizationIdSchema,
documentId: documentIdSchema,

View File

@@ -1,53 +1,59 @@
import type { RouteDefinitionContext } from '../app/server.types';
import { pick } from 'lodash-es';
import { z } from 'zod';
import { requireAuthentication } from '../app/auth/auth.middleware';
import { getUser } from '../app/auth/auth.models';
import { createRolesRepository } from '../roles/roles.repository';
import { validateJsonBody } from '../shared/validation/validation';
import { createUsersRepository } from './users.repository';
export async function registerUsersPrivateRoutes(context: RouteDefinitionContext) {
export async function registerUsersRoutes(context: RouteDefinitionContext) {
setupGetCurrentUserRoute(context);
setupUpdateUserRoute(context);
}
function setupGetCurrentUserRoute({ app, db }: RouteDefinitionContext) {
app.get('/api/users/me', async (context) => {
const { userId } = getUser({ context });
app.get(
'/api/users/me',
requireAuthentication(),
async (context) => {
const { userId } = getUser({ context });
const usersRepository = createUsersRepository({ db });
const rolesRepository = createRolesRepository({ db });
const usersRepository = createUsersRepository({ db });
const rolesRepository = createRolesRepository({ db });
const [
{ user },
{ roles },
] = await Promise.all([
usersRepository.getUserByIdOrThrow({ userId }),
rolesRepository.getUserRoles({ userId }),
]);
const [
{ user },
{ roles },
] = await Promise.all([
usersRepository.getUserByIdOrThrow({ userId }),
rolesRepository.getUserRoles({ userId }),
]);
return context.json({
user: {
roles,
...pick(
user,
[
'id',
'email',
'name',
'createdAt',
'updatedAt',
'planId',
],
),
},
});
});
return context.json({
user: {
roles,
...pick(
user,
[
'id',
'email',
'name',
'createdAt',
'updatedAt',
'planId',
],
),
},
});
},
);
}
function setupUpdateUserRoute({ app, db }: RouteDefinitionContext) {
app.put(
'/api/users/me',
requireAuthentication(),
validateJsonBody(z.object({
name: z.string().min(1).max(50),
})),

View File

@@ -34,6 +34,7 @@ COPY --from=build /app/apps/papra-client/dist ./public
EXPOSE 1221
ENV NODE_ENV=production
ENV SERVER_SERVE_PUBLIC_DIR=true
ENV DATABASE_URL=file:./app-data/db/db.sqlite
ENV DOCUMENT_STORAGE_FILESYSTEM_ROOT=./app-data/documents

View File

@@ -44,6 +44,7 @@ USER nonroot
EXPOSE 1221
ENV NODE_ENV=production
ENV SERVER_SERVE_PUBLIC_DIR=true
ENV DATABASE_URL=file:./app-data/db/db.sqlite
ENV DOCUMENT_STORAGE_FILESYSTEM_ROOT=./app-data/documents

10
pnpm-lock.yaml generated
View File

@@ -277,6 +277,9 @@ importers:
mime-types:
specifier: ^3.0.1
version: 3.0.1
nanoid:
specifier: ^5.1.5
version: 5.1.5
node-cron:
specifier: ^3.0.3
version: 3.0.3
@@ -5240,6 +5243,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.5:
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
engines: {node: ^18 || >=20}
hasBin: true
nanostores@0.11.4:
resolution: {integrity: sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -12804,6 +12812,8 @@ snapshots:
nanoid@3.3.10: {}
nanoid@5.1.5: {}
nanostores@0.11.4: {}
napi-build-utils@2.0.0: {}