mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-17 20:25:42 -06:00
feat(server): added api-keys (#248)
This commit is contained in:
committed by
GitHub
parent
9cba84e38b
commit
cc2edc59b0
@@ -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
|
||||
|
||||
28
apps/papra-client/src/modules/api-keys/api-keys.constants.ts
Normal file
28
apps/papra-client/src/modules/api-keys/api-keys.constants.ts
Normal 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);
|
||||
56
apps/papra-client/src/modules/api-keys/api-keys.services.ts
Normal file
56
apps/papra-client/src/modules/api-keys/api-keys.services.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
12
apps/papra-client/src/modules/api-keys/api-keys.types.ts
Normal file
12
apps/papra-client/src/modules/api-keys/api-keys.types.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
139
apps/papra-client/src/modules/api-keys/pages/api-keys.page.tsx
Normal file
139
apps/papra-client/src/modules/api-keys/pages/api-keys.page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -17,7 +17,7 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
'/api/': {
|
||||
target: 'http://localhost:1221',
|
||||
},
|
||||
},
|
||||
|
||||
24
apps/papra-server/migrations/0003_api-keys.sql
Normal file
24
apps/papra-server/migrations/0003_api-keys.sql
Normal 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`);
|
||||
1612
apps/papra-server/migrations/meta/0003_snapshot.json
Normal file
1612
apps/papra-server/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,13 @@
|
||||
"when": 1743938048080,
|
||||
"tag": "0002_tagging_rules",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1745131802627,
|
||||
"tag": "0003_api-keys",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
19
apps/papra-server/src/modules/api-keys/api-keys.constants.ts
Normal file
19
apps/papra-server/src/modules/api-keys/api-keys.constants.ts
Normal 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));
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
14
apps/papra-server/src/modules/api-keys/api-keys.models.ts
Normal file
14
apps/papra-server/src/modules/api-keys/api-keys.models.ts
Normal 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' }),
|
||||
};
|
||||
}
|
||||
@@ -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: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
149
apps/papra-server/src/modules/api-keys/api-keys.repository.ts
Normal file
149
apps/papra-server/src/modules/api-keys/api-keys.repository.ts
Normal 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 };
|
||||
}
|
||||
99
apps/papra-server/src/modules/api-keys/api-keys.routes.ts
Normal file
99
apps/papra-server/src/modules/api-keys/api-keys.routes.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}` };
|
||||
}
|
||||
42
apps/papra-server/src/modules/api-keys/api-keys.tables.ts
Normal file
42
apps/papra-server/src/modules/api-keys/api-keys.tables.ts
Normal 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' }),
|
||||
});
|
||||
6
apps/papra-server/src/modules/api-keys/api-keys.types.ts
Normal file
6
apps/papra-server/src/modules/api-keys/api-keys.types.ts
Normal 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;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
53
apps/papra-server/src/modules/api-keys/api-keys.usecases.ts
Normal file
53
apps/papra-server/src/modules/api-keys/api-keys.usecases.ts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
22
apps/papra-server/src/modules/app/auth/auth.middleware.ts
Normal file
22
apps/papra-server/src/modules/app/auth/auth.middleware.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
3
apps/papra-server/src/modules/app/auth/auth.types.ts
Normal file
3
apps/papra-server/src/modules/app/auth/auth.types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Auth } from './auth.services';
|
||||
|
||||
export type Session = Auth['$Infer']['Session']['session'];
|
||||
@@ -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 = {
|
||||
|
||||
69
apps/papra-server/src/modules/app/server.routes.test.ts
Normal file
69
apps/papra-server/src/modules/app/server.routes.test.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
20
apps/papra-server/src/modules/shared/crypto/hash.test.ts
Normal file
20
apps/papra-server/src/modules/shared/crypto/hash.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
5
apps/papra-server/src/modules/shared/crypto/hash.ts
Normal file
5
apps/papra-server/src/modules/shared/crypto/hash.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
10
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user