diff --git a/apps/gateway/.eslintrc.json b/apps/gateway/.eslintrc.json new file mode 100644 index 000000000..bda6f2fdb --- /dev/null +++ b/apps/gateway/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": ["./node_modules/@latitude-data/eslint-config/library.js"], + "env": { + "node": true + } +} diff --git a/apps/gateway/.gitignore b/apps/gateway/.gitignore new file mode 100644 index 000000000..e319e0635 --- /dev/null +++ b/apps/gateway/.gitignore @@ -0,0 +1,33 @@ +# prod +dist/ + +# dev +.yarn/ +!.yarn/releases +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# deps +node_modules/ +.wrangler + +# env +.env +.env.production +.dev.vars + +# logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# misc +.DS_Store diff --git a/apps/gateway/README.md b/apps/gateway/README.md new file mode 100644 index 000000000..cc58e962d --- /dev/null +++ b/apps/gateway/README.md @@ -0,0 +1,8 @@ +``` +npm install +npm run dev +``` + +``` +npm run deploy +``` diff --git a/apps/gateway/package.json b/apps/gateway/package.json new file mode 100644 index 000000000..227432bd6 --- /dev/null +++ b/apps/gateway/package.json @@ -0,0 +1,30 @@ +{ + "name": "@latitude-data/gateway", + "type": "module", + "scripts": { + "build": "tsc --build tsconfig.prod.json --verbose", + "dev": "tsx watch src", + "lint": "eslint src/", + "prettier": "prettier --write \"**/*.{ts,tsx,md}\"", + "start": "node -r module-alias/register ./dist --env=production", + "tc": "tsc --noEmit", + "test": "vitest --run", + "test:watch": "vitest" + }, + "dependencies": { + "@hono/node-server": "^1.12.0", + "@latitude-data/core": "workspace:^", + "@latitude-data/env": "workspace:^", + "hono": "^4.5.3" + }, + "devDependencies": { + "@latitude-data/eslint-config": "workspace:^", + "@latitude-data/typescript-config": "workspace:^", + "eslint": "8", + "jet-paths": "^1.0.6", + "tsx": "^4.16.2", + "typescript": "^5.5.4", + "vite-tsconfig-paths": "^4.3.2", + "vitest": "^2.0.4" + } +} diff --git a/apps/gateway/src/common/httpStatusCodes.ts b/apps/gateway/src/common/httpStatusCodes.ts new file mode 100644 index 000000000..645723ad2 --- /dev/null +++ b/apps/gateway/src/common/httpStatusCodes.ts @@ -0,0 +1,384 @@ +/** + * This file was copied from here: https://gist.github.com/scokmen/f813c904ef79022e84ab2409574d1b45 + */ + +/** + * Hypertext Transfer Protocol (HTTP) response status codes. + * @see {@link https://en.wikipedia.org/wiki/List_of_HTTP_status_codes} + */ +enum HttpStatusCodes { + /** + * The server has received the request headers and the client should proceed to send the request body + * (in the case of a request for which a body needs to be sent; for example, a POST request). + * Sending a large request body to a server after a request has been rejected for inappropriate headers would be inefficient. + * To have a server check the request's headers, a client must send Expect: 100-continue as a header in its initial request + * and receive a 100 Continue status code in response before sending the body. The response 417 Expectation Failed indicates the request should not be continued. + */ + CONTINUE = 100, + + /** + * The requester has asked the server to switch protocols and the server has agreed to do so. + */ + SWITCHING_PROTOCOLS = 101, + + /** + * A WebDAV request may contain many sub-requests involving file operations, requiring a long time to complete the request. + * This code indicates that the server has received and is processing the request, but no response is available yet. + * This prevents the client from timing out and assuming the request was lost. + */ + PROCESSING = 102, + + /** + * Standard response for successful HTTP requests. + * The actual response will depend on the request method used. + * In a GET request, the response will contain an entity corresponding to the requested resource. + * In a POST request, the response will contain an entity describing or containing the result of the action. + */ + OK = 200, + + /** + * The request has been fulfilled, resulting in the creation of a new resource. + */ + CREATED = 201, + + /** + * The request has been accepted for processing, but the processing has not been completed. + * The request might or might not be eventually acted upon, and may be disallowed when processing occurs. + */ + ACCEPTED = 202, + + /** + * SINCE HTTP/1.1 + * The server is a transforming proxy that received a 200 OK from its origin, + * but is returning a modified version of the origin's response. + */ + NON_AUTHORITATIVE_INFORMATION = 203, + + /** + * The server successfully processed the request and is not returning any content. + */ + NO_CONTENT = 204, + + /** + * The server successfully processed the request, but is not returning any content. + * Unlike a 204 response, this response requires that the requester reset the document view. + */ + RESET_CONTENT = 205, + + /** + * The server is delivering only part of the resource (byte serving) due to a range header sent by the client. + * The range header is used by HTTP clients to enable resuming of interrupted downloads, + * or split a download into multiple simultaneous streams. + */ + PARTIAL_CONTENT = 206, + + /** + * The message body that follows is an XML message and can contain a number of separate response codes, + * depending on how many sub-requests were made. + */ + MULTI_STATUS = 207, + + /** + * The members of a DAV binding have already been enumerated in a preceding part of the (multistatus) response, + * and are not being included again. + */ + ALREADY_REPORTED = 208, + + /** + * The server has fulfilled a request for the resource, + * and the response is a representation of the result of one or more instance-manipulations applied to the current instance. + */ + IM_USED = 226, + + /** + * Indicates multiple options for the resource from which the client may choose (via agent-driven content negotiation). + * For example, this code could be used to present multiple video format options, + * to list files with different filename extensions, or to suggest word-sense disambiguation. + */ + MULTIPLE_CHOICES = 300, + + /** + * This and all future requests should be directed to the given URI. + */ + MOVED_PERMANENTLY = 301, + + /** + * This is an example of industry practice contradicting the standard. + * The HTTP/1.0 specification (RFC 1945) required the client to perform a temporary redirect + * (the original describing phrase was "Moved Temporarily"), but popular browsers implemented 302 + * with the functionality of a 303 See Other. Therefore, HTTP/1.1 added status codes 303 and 307 + * to distinguish between the two behaviours. However, some Web applications and frameworks + * use the 302 status code as if it were the 303. + */ + FOUND = 302, + + /** + * SINCE HTTP/1.1 + * The response to the request can be found under another URI using a GET method. + * When received in response to a POST (or PUT/DELETE), the client should presume that + * the server has received the data and should issue a redirect with a separate GET message. + */ + SEE_OTHER = 303, + + /** + * Indicates that the resource has not been modified since the version specified by the request headers If-Modified-Since or If-None-Match. + * In such case, there is no need to retransmit the resource since the client still has a previously-downloaded copy. + */ + NOT_MODIFIED = 304, + + /** + * SINCE HTTP/1.1 + * The requested resource is available only through a proxy, the address for which is provided in the response. + * Many HTTP clients (such as Mozilla and Internet Explorer) do not correctly handle responses with this status code, primarily for security reasons. + */ + USE_PROXY = 305, + + /** + * No longer used. Originally meant "Subsequent requests should use the specified proxy." + */ + SWITCH_PROXY = 306, + + /** + * SINCE HTTP/1.1 + * In this case, the request should be repeated with another URI; however, future requests should still use the original URI. + * In contrast to how 302 was historically implemented, the request method is not allowed to be changed when reissuing the original request. + * For example, a POST request should be repeated using another POST request. + */ + TEMPORARY_REDIRECT = 307, + + /** + * The request and all future requests should be repeated using another URI. + * 307 and 308 parallel the behaviors of 302 and 301, but do not allow the HTTP method to change. + * So, for example, submitting a form to a permanently redirected resource may continue smoothly. + */ + PERMANENT_REDIRECT = 308, + + /** + * The server cannot or will not process the request due to an apparent client error + * (e.g., malformed request syntax, too large size, invalid request message framing, or deceptive request routing). + */ + BAD_REQUEST = 400, + + /** + * Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet + * been provided. The response must include a WWW-Authenticate header field containing a challenge applicable to the + * requested resource. See Basic access authentication and Digest access authentication. 401 semantically means + * "unauthenticated",i.e. the user does not have the necessary credentials. + */ + UNAUTHORIZED = 401, + + /** + * Reserved for future use. The original intention was that this code might be used as part of some form of digital + * cash or micro payment scheme, but that has not happened, and this code is not usually used. + * Google Developers API uses this status if a particular developer has exceeded the daily limit on requests. + */ + PAYMENT_REQUIRED = 402, + + /** + * The request was valid, but the server is refusing action. + * The user might not have the necessary permissions for a resource. + */ + FORBIDDEN = 403, + + /** + * The requested resource could not be found but may be available in the future. + * Subsequent requests by the client are permissible. + */ + NOT_FOUND = 404, + + /** + * A request method is not supported for the requested resource; + * for example, a GET request on a form that requires data to be presented via POST, or a PUT request on a read-only resource. + */ + METHOD_NOT_ALLOWED = 405, + + /** + * The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request. + */ + NOT_ACCEPTABLE = 406, + + /** + * The client must first authenticate itself with the proxy. + */ + PROXY_AUTHENTICATION_REQUIRED = 407, + + /** + * The server timed out waiting for the request. + * According to HTTP specifications: + * "The client did not produce a request within the time that the server was prepared to wait. The client MAY repeat the request without modifications at any later time." + */ + REQUEST_TIMEOUT = 408, + + /** + * Indicates that the request could not be processed because of conflict in the request, + * such as an edit conflict between multiple simultaneous updates. + */ + CONFLICT = 409, + + /** + * Indicates that the resource requested is no longer available and will not be available again. + * This should be used when a resource has been intentionally removed and the resource should be purged. + * Upon receiving a 410 status code, the client should not request the resource in the future. + * Clients such as search engines should remove the resource from their indices. + * Most use cases do not require clients and search engines to purge the resource, and a "404 Not Found" may be used instead. + */ + GONE = 410, + + /** + * The request did not specify the length of its content, which is required by the requested resource. + */ + LENGTH_REQUIRED = 411, + + /** + * The server does not meet one of the preconditions that the requester put on the request. + */ + PRECONDITION_FAILED = 412, + + /** + * The request is larger than the server is willing or able to process. Previously called "Request Entity Too Large". + */ + PAYLOAD_TOO_LARGE = 413, + + /** + * The URI provided was too long for the server to process. Often the result of too much data being encoded as a query-string of a GET request, + * in which case it should be converted to a POST request. + * Called "Request-URI Too Long" previously. + */ + URI_TOO_LONG = 414, + + /** + * The request entity has a media type which the server or resource does not support. + * For example, the client uploads an image as image/svg+xml, but the server requires that images use a different format. + */ + UNSUPPORTED_MEDIA_TYPE = 415, + + /** + * The client has asked for a portion of the file (byte serving), but the server cannot supply that portion. + * For example, if the client asked for a part of the file that lies beyond the end of the file. + * Called "Requested Range Not Satisfiable" previously. + */ + RANGE_NOT_SATISFIABLE = 416, + + /** + * The server cannot meet the requirements of the Expect request-header field. + */ + EXPECTATION_FAILED = 417, + + /** + * This code was defined in 1998 as one of the traditional IETF April Fools' jokes, in RFC 2324, Hyper Text Coffee Pot Control Protocol, + * and is not expected to be implemented by actual HTTP servers. The RFC specifies this code should be returned by + * teapots requested to brew coffee. This HTTP status is used as an Easter egg in some websites, including Google.com. + */ + I_AM_A_TEAPOT = 418, + + /** + * The request was directed at a server that is not able to produce a response (for example because a connection reuse). + */ + MISDIRECTED_REQUEST = 421, + + /** + * The request was well-formed but was unable to be followed due to semantic errors. + */ + UNPROCESSABLE_ENTITY = 422, + + /** + * The resource that is being accessed is locked. + */ + LOCKED = 423, + + /** + * The request failed due to failure of a previous request (e.g., a PROPPATCH). + */ + FAILED_DEPENDENCY = 424, + + /** + * The client should switch to a different protocol such as TLS/1.0, given in the Upgrade header field. + */ + UPGRADE_REQUIRED = 426, + + /** + * The origin server requires the request to be conditional. + * Intended to prevent "the 'lost update' problem, where a client + * GETs a resource's state, modifies it, and PUTs it back to the server, + * when meanwhile a third party has modified the state on the server, leading to a conflict." + */ + PRECONDITION_REQUIRED = 428, + + /** + * The user has sent too many requests in a given amount of time. Intended for use with rate-limiting schemes. + */ + TOO_MANY_REQUESTS = 429, + + /** + * The server is unwilling to process the request because either an individual header field, + * or all the header fields collectively, are too large. + */ + REQUEST_HEADER_FIELDS_TOO_LARGE = 431, + + /** + * A server operator has received a legal demand to deny access to a resource or to a set of resources + * that includes the requested resource. The code 451 was chosen as a reference to the novel Fahrenheit 451. + */ + UNAVAILABLE_FOR_LEGAL_REASONS = 451, + + /** + * A generic error message, given when an unexpected condition was encountered and no more specific message is suitable. + */ + INTERNAL_SERVER_ERROR = 500, + + /** + * The server either does not recognize the request method, or it lacks the ability to fulfill the request. + * Usually this implies future availability (e.g., a new feature of a web-service API). + */ + NOT_IMPLEMENTED = 501, + + /** + * The server was acting as a gateway or proxy and received an invalid response from the upstream server. + */ + BAD_GATEWAY = 502, + + /** + * The server is currently unavailable (because it is overloaded or down for maintenance). + * Generally, this is a temporary state. + */ + SERVICE_UNAVAILABLE = 503, + + /** + * The server was acting as a gateway or proxy and did not receive a timely response from the upstream server. + */ + GATEWAY_TIMEOUT = 504, + + /** + * The server does not support the HTTP protocol version used in the request + */ + HTTP_VERSION_NOT_SUPPORTED = 505, + + /** + * Transparent content negotiation for the request results in a circular reference. + */ + VARIANT_ALSO_NEGOTIATES = 506, + + /** + * The server is unable to store the representation needed to complete the request. + */ + INSUFFICIENT_STORAGE = 507, + + /** + * The server detected an infinite loop while processing the request. + */ + LOOP_DETECTED = 508, + + /** + * Further extensions to the request are required for the server to fulfill it. + */ + NOT_EXTENDED = 510, + + /** + * The client needs to authenticate to gain network access. + * Intended for use by intercepting proxies used to control access to the network (e.g., "captive portals" used + * to require agreement to Terms of Service before granting full Internet access via a Wi-Fi hotspot). + */ + NETWORK_AUTHENTICATION_REQUIRED = 511, +} + +export default HttpStatusCodes diff --git a/apps/gateway/src/common/routes.ts b/apps/gateway/src/common/routes.ts new file mode 100644 index 000000000..781f4b3c4 --- /dev/null +++ b/apps/gateway/src/common/routes.ts @@ -0,0 +1,15 @@ +const ROUTES = { + Base: '', + Api: { + Base: '/api', + V1: { + Base: '/v1', + Documents: { + Base: '/projects/:projectId/commits/:commitUuid/documents', + Get: '/:documentPath{.+}', + }, + }, + }, +} + +export default ROUTES diff --git a/apps/gateway/src/index.ts b/apps/gateway/src/index.ts new file mode 100644 index 000000000..ea893e277 --- /dev/null +++ b/apps/gateway/src/index.ts @@ -0,0 +1,31 @@ +import '@latitude-data/env' + +import { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { logger } from 'hono/logger' +import jetPaths from 'jet-paths' + +import ROUTES from './common/routes' +import authMiddleware from './middlewares/auth' +import errorHandlerMiddleware from './middlewares/errorHandler' +import { documentsRouter } from './routes/api/v1/projects/:projectId/commits/:commitUuid/documents' + +const app = new Hono() + +// Middlewares +if (process.env.NODE_ENV !== 'test') { + app.use(logger()) +} +app.use(authMiddleware()) + +// Routers +app.route(jetPaths(ROUTES).Api.V1.Documents.Base, documentsRouter) + +// Must be the last one! +app.use(errorHandlerMiddleware()) + +serve(app, (info) => { + console.log(`Listening on http://localhost:${info.port}`) +}) + +export default app diff --git a/apps/gateway/src/middlewares/auth.ts b/apps/gateway/src/middlewares/auth.ts new file mode 100644 index 000000000..400900b55 --- /dev/null +++ b/apps/gateway/src/middlewares/auth.ts @@ -0,0 +1,32 @@ +import { + unsafelyFindWorkspace, + unsafelyGetApiKeyByToken, + Workspace, +} from '@latitude-data/core' +import { Context } from 'hono' +import { bearerAuth } from 'hono/bearer-auth' + +declare module 'hono' { + interface ContextVariableMap { + workspace: Workspace + } +} + +const authMiddleware = () => + bearerAuth({ + verifyToken: async (token: string, c: Context) => { + const apiKeyResult = await unsafelyGetApiKeyByToken({ token }) + if (apiKeyResult.error) return false + + const workspaceResult = await unsafelyFindWorkspace( + apiKeyResult.value.workspaceId, + ) + if (workspaceResult.error) return false + + c.set('workspace', workspaceResult.value) + + return true + }, + }) + +export default authMiddleware diff --git a/apps/gateway/src/middlewares/errorHandler.ts b/apps/gateway/src/middlewares/errorHandler.ts new file mode 100644 index 000000000..7cc836f33 --- /dev/null +++ b/apps/gateway/src/middlewares/errorHandler.ts @@ -0,0 +1,40 @@ +import { LatitudeError, UnprocessableEntityError } from '@latitude-data/core' +import { createMiddleware } from 'hono/factory' + +import HttpStatusCodes from '../common/httpStatusCodes' + +const errorHandlerMiddleware = () => + createMiddleware(async (c, next) => { + const err = c.error! + if (!err) return next() + + if (process.env.NODE_ENV !== 'test') { + console.error(err.message) + } + + if (err instanceof UnprocessableEntityError) { + return Response.json( + { + name: err.name, + message: err.message, + details: err.details, + }, + { status: err.statusCode }, + ) + } else if (err instanceof LatitudeError) { + return Response.json( + { + message: err.message, + details: err.details, + }, + { status: err.statusCode }, + ) + } else { + return Response.json( + { message: err.message }, + { status: HttpStatusCodes.BAD_REQUEST }, + ) + } + }) + +export default errorHandlerMiddleware diff --git a/apps/gateway/src/routes/api/v1/projects/:projectId/commits/:commitUuid/documents/index.test.ts b/apps/gateway/src/routes/api/v1/projects/:projectId/commits/:commitUuid/documents/index.test.ts new file mode 100644 index 000000000..4fc46f54c --- /dev/null +++ b/apps/gateway/src/routes/api/v1/projects/:projectId/commits/:commitUuid/documents/index.test.ts @@ -0,0 +1,56 @@ +import app from '$/index' +import { database } from '$core/client' +import { DocumentVersionsRepository } from '$core/repositories' +import { apiKeys, projects } from '$core/schema' +import { mergeCommit } from '$core/services' +import { createDraft } from '$core/tests/factories/commits' +import { createDocumentVersion } from '$core/tests/factories/documents' +import { createWorkspace } from '$core/tests/factories/workspaces' +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' + +describe('GET documents', () => { + describe('unauthorized', () => { + it('fails', async () => { + const res = await app.request( + '/api/v1/projects/1/commits/asldkfjhsadl/documents/path/to/document', + ) + + expect(res.status).toBe(401) + }) + }) + + describe('authorized', () => { + it('succeeds', async () => { + const session = await createWorkspace() + const project = await database.query.projects.findFirst({ + where: eq(projects.workspaceId, session.workspace.id), + }) + const apikey = await database.query.apiKeys.findFirst({ + where: eq(apiKeys.workspaceId, session.workspace.id), + }) + const path = '/path/to/document' + const { commit } = await createDraft({ project }) + const document = await createDocumentVersion({ commit: commit!, path }) + + await mergeCommit(commit).then((r) => r.unwrap()) + + // TODO: We refetch the document because merging a commit actually replaces the + // draft document with a new one. Review this behavior. + const docsScope = new DocumentVersionsRepository(session.workspace.id) + const documentVersion = await docsScope + .getDocumentByPath({ commit, path }) + .then((r) => r.unwrap()) + + const route = `/api/v1/projects/${project!.id}/commits/${commit!.uuid}/documents/${document.documentVersion.path.slice(1)}` + const res = await app.request(route, { + headers: { + Authorization: `Bearer ${apikey!.token}`, + }, + }) + + expect(res.status).toBe(200) + expect(await res.json().then((r) => r.id)).toEqual(documentVersion.id) + }) + }) +}) diff --git a/apps/gateway/src/routes/api/v1/projects/:projectId/commits/:commitUuid/documents/index.ts b/apps/gateway/src/routes/api/v1/projects/:projectId/commits/:commitUuid/documents/index.ts new file mode 100644 index 000000000..98f3cd4ec --- /dev/null +++ b/apps/gateway/src/routes/api/v1/projects/:projectId/commits/:commitUuid/documents/index.ts @@ -0,0 +1,39 @@ +import ROUTES from '$/common/routes' +import { + CommitsRepository, + DocumentVersionsRepository, + ProjectsRepository, +} from '$core/repositories' +import { Hono } from 'hono' + +const router = new Hono() + +router.get(ROUTES.Api.V1.Documents.Get, async (c) => { + const workspace = c.get('workspace') + // @ts-expect-error - hono cannot infer the params type from the route path + // unless you explicitely write it in the handler + const { projectId, commitUuid, documentPath } = c.req.param() + + // scopes + const projectsScope = new ProjectsRepository(workspace.id) + const commitsScope = new CommitsRepository(workspace.id) + const docsScope = new DocumentVersionsRepository(workspace.id) + + // get project, commit, and document + const project = await projectsScope + .getProjectById(projectId) + .then((r) => r.unwrap()) + const commit = await commitsScope + .getCommitByUuid({ project, uuid: commitUuid }) + .then((r) => r.unwrap()) + const document = await docsScope + .getDocumentByPath({ + commit, + path: '/' + documentPath, + }) + .then((r) => r.unwrap()) + + return c.json(document) +}) + +export { router as documentsRouter } diff --git a/apps/gateway/test/setup.ts b/apps/gateway/test/setup.ts new file mode 100644 index 000000000..d653fc1bf --- /dev/null +++ b/apps/gateway/test/setup.ts @@ -0,0 +1,5 @@ +// vitest-env.d.ts + +import useTestDatabase from '$core/tests/useTestDatabase' + +useTestDatabase() diff --git a/apps/gateway/test/support/paths.ts b/apps/gateway/test/support/paths.ts new file mode 100644 index 000000000..c9c3e80af --- /dev/null +++ b/apps/gateway/test/support/paths.ts @@ -0,0 +1,6 @@ +import ROUTES from '$/common/routes' +import jetPaths from 'jet-paths' + +const paths = jetPaths(ROUTES) + +export default paths diff --git a/apps/gateway/test/vitest-env.d.ts b/apps/gateway/test/vitest-env.d.ts new file mode 100644 index 000000000..a176079c9 --- /dev/null +++ b/apps/gateway/test/vitest-env.d.ts @@ -0,0 +1,9 @@ +import 'vitest' + +import * as factories from './factories' + +declare module 'vitest' { + export interface TestContext { + factories: typeof factories + } +} diff --git a/apps/gateway/tsconfig.json b/apps/gateway/tsconfig.json new file mode 100644 index 000000000..46a901ad0 --- /dev/null +++ b/apps/gateway/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "@latitude-data/typescript-config/base.json", + "compilerOptions": { + "moduleResolution": "Bundler", + "outDir": "./dist", + "baseUrl": ".", + "paths": { + "$/*": ["./src/*"], + "$compiler/*": ["../../packages/compiler/src/*"], + "$core/*": ["../../packages/core/src/*"], + "$jobs/*": ["../../packages/jobs/src/*"], + "$ui/*": ["../../packages/web-ui/src/*"], + "@latitude-data/core": ["../../packages/core/src/*"], + "@latitude-data/jobs": ["../../packages/jobs/src/*"], + "@latitude-data/web-ui": ["../../packages/web-ui/src/*"], + "acorn": ["node_modules/@latitude-data/typescript-config/types/acorn"] + } + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/gateway/tsconfig.prod.json b/apps/gateway/tsconfig.prod.json new file mode 100644 index 000000000..8a8ddbc37 --- /dev/null +++ b/apps/gateway/tsconfig.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": false, + "removeComments": true, + "noEmit": false + }, + "exclude": ["test"] +} diff --git a/apps/gateway/vitest.config.ts b/apps/gateway/vitest.config.ts new file mode 100644 index 000000000..fdb47c2d4 --- /dev/null +++ b/apps/gateway/vitest.config.ts @@ -0,0 +1,9 @@ +import tsconfigPaths from 'vite-tsconfig-paths' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + setupFiles: ['./test/setup.ts'], + }, +}) diff --git a/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts b/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts deleted file mode 100644 index d98a4534f..000000000 --- a/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { HEAD_COMMIT, mergeCommit } from '@latitude-data/core' -import { LatitudeRequest } from '$/middleware' -import useTestDatabase from '$core/tests/useTestDatabase' -import { describe, expect, test } from 'vitest' - -import { GET } from './route' - -useTestDatabase() - -describe('GET documentVersion', () => { - test('returns the document by path', async (ctx) => { - const { project } = await ctx.factories.createProject() - let { commit } = await ctx.factories.createDraft({ project }) - const { documentVersion: doc } = await ctx.factories.createDocumentVersion({ - commit, - }) - - commit = await mergeCommit(commit).then((r) => r.unwrap()) - const req = new LatitudeRequest( - 'http://localhost/api/projects/projectId/commits/commitUuid/path/to/doc', - ) - - req.workspaceId = project.workspaceId - - const response = await GET(req, { - params: { - projectId: project.id, - commitUuid: commit.uuid, - documentPath: doc.path.split('/'), - }, - }) - - expect(response.status).toBe(200) - const responseDoc = await response.json() - expect(responseDoc.documentUuid).toEqual(doc.documentUuid) - expect(responseDoc.commitId).toEqual(doc.commitId) - }) - - test('returns the document in main branch if commitUuid is HEAD', async (ctx) => { - const { project } = await ctx.factories.createProject() - let { commit } = await ctx.factories.createDraft({ project }) - const { documentVersion: doc } = await ctx.factories.createDocumentVersion({ - commit, - }) - - commit = await mergeCommit(commit).then((r) => r.unwrap()) - const req = new LatitudeRequest( - 'http://localhost/api/projects/projectId/commits/commitUuid/path/to/doc', - ) - req.workspaceId = project.workspaceId - - const response = await GET(req, { - params: { - projectId: project.id, - commitUuid: HEAD_COMMIT, - documentPath: doc.path.split('/'), - }, - }) - - expect(response.status).toBe(200) - const responseDoc = await response.json() - expect(responseDoc.documentUuid).toEqual(doc.documentUuid) - expect(responseDoc.commitId).toEqual(doc.commitId) - }) - - test('returns 404 if document is not found', async (ctx) => { - const { project } = await ctx.factories.createProject() - let { commit } = await ctx.factories.createDraft({ project }) - - commit = await mergeCommit(commit).then((r) => r.unwrap()) - const req = new LatitudeRequest( - 'http://localhost/api/projects/projectId/commits/commitUuid/path/to/doc', - ) - req.workspaceId = project.workspaceId - - const response = await GET(req, { - params: { - projectId: project.id, - commitUuid: commit.uuid, - documentPath: ['path', 'to', 'doc'], - }, - }) - - expect(response.status).toBe(404) - }) - - test('returns the document even if commit is not merged', async (ctx) => { - const { project } = await ctx.factories.createProject() - const { commit } = await ctx.factories.createDraft({ project }) - const { documentVersion: doc } = await ctx.factories.createDocumentVersion({ - commit, - }) - - const req = new LatitudeRequest( - 'http://localhost/api/projects/projectId/commits/commitUuid/path/to/doc', - ) - req.workspaceId = project.workspaceId - - const response = await GET(req, { - params: { - projectId: project.id, - commitUuid: commit.uuid, - documentPath: doc.path.split('/'), - }, - }) - - expect(response.status).toBe(200) - const responseDoc = await response.json() - expect(responseDoc.documentUuid).toEqual(doc.documentUuid) - expect(responseDoc.commitId).toEqual(doc.commitId) - }) -}) diff --git a/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.ts b/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.ts deleted file mode 100644 index b9acc9022..000000000 --- a/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - CommitsRepository, - DocumentVersionsRepository, - ProjectsRepository, -} from '@latitude-data/core' -import apiRoute from '$/helpers/api/route' -import { LatitudeRequest } from '$/middleware' - -export async function GET( - req: LatitudeRequest, - { - params, - }: { - params: { - commitUuid: string - projectId: number - documentPath: string[] - } - }, -) { - return apiRoute(async () => { - const workspaceId = req.workspaceId! - const { commitUuid, projectId, documentPath } = params - const commitsScope = new CommitsRepository(workspaceId) - const projectsScope = new ProjectsRepository(workspaceId) - const projectResult = await projectsScope.getProjectById(projectId) - if (projectResult.error) return projectResult - - const commit = await commitsScope - .getCommitByUuid({ uuid: commitUuid, project: projectResult.value! }) - .then((r) => r.unwrap()) - const documentVersionsScope = new DocumentVersionsRepository(workspaceId) - const result = await documentVersionsScope.getDocumentByPath({ - commit, - path: documentPath.join('/'), - }) - const document = result.unwrap() - - return Response.json(document) - }) -} diff --git a/apps/web/src/app/api/projects/[projectId]/commits/[commitUuid]/documents/route.ts b/apps/web/src/app/api/projects/[projectId]/commits/[commitUuid]/documents/route.ts deleted file mode 100644 index 580b1036b..000000000 --- a/apps/web/src/app/api/projects/[projectId]/commits/[commitUuid]/documents/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - CommitsRepository, - DocumentVersionsRepository, - ProjectsRepository, -} from '@latitude-data/core' -import { LatitudeRequest } from '$/middleware' -import { NextResponse } from 'next/server' - -export async function GET( - req: LatitudeRequest, - { - params: { commitUuid, projectId }, - }: { params: { commitUuid: string; projectId: number } }, -) { - try { - const workspaceId = req.workspaceId! - const scope = new DocumentVersionsRepository(workspaceId) - const commitsScope = new CommitsRepository(workspaceId) - const projectsScope = new ProjectsRepository(workspaceId) - const project = await projectsScope - .getProjectById(projectId) - .then((r) => r.unwrap()) - const commit = await commitsScope - .getCommitByUuid({ uuid: commitUuid, project }) - .then((r) => r.unwrap()) - const documents = await scope.getDocumentsAtCommit(commit) - - return NextResponse.json(documents.unwrap()) - } catch (err: unknown) { - const error = err as Error - return NextResponse.json({ error: error.message }, { status: 500 }) - } -} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts deleted file mode 100644 index 237e5c5d3..000000000 --- a/apps/web/src/middleware.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { unsafelyGetApiKey } from '@latitude-data/core' -import { NextRequest, NextResponse } from 'next/server' - -import env from './env' -import { apiUnauthorized } from './helpers/api/errors' - -export class LatitudeRequest extends NextRequest { - workspaceId?: number -} - -export async function middleware(request: LatitudeRequest) { - const { headers } = request - const [type, token] = headers.get('Authorization')?.split(' ') ?? [] - if (type !== 'Bearer' || token !== env.LATITUDE_API_KEY) { - return apiUnauthorized() - } - - const result = await unsafelyGetApiKey({ token }) - if (result.error) return apiUnauthorized() - - request.workspaceId = result.value.workspaceId - - return NextResponse.next() -} - -export const config = { - matcher: '/api/v1/(.*)', -} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 0265ac74f..7f85005ff 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -12,13 +12,13 @@ "baseUrl": ".", "paths": { "$/*": ["./src/*"], - "@latitude-data/core": ["../../packages/core/src/*"], - "@latitude-data/jobs": ["../../packages/jobs/src/*"], - "@latitude-data/web-ui": ["../../packages/web-ui/src/*"], + "$compiler/*": ["../../packages/compiler/src/*"], "$core/*": ["../../packages/core/src/*"], "$jobs/*": ["../../packages/jobs/src/*"], "$ui/*": ["../../packages/web-ui/src/*"], - "$compiler/*": ["../../packages/compiler/src/*"], + "@latitude-data/core": ["../../packages/core/src/*"], + "@latitude-data/jobs": ["../../packages/jobs/src/*"], + "@latitude-data/web-ui": ["../../packages/web-ui/src/*"], "acorn": ["node_modules/@latitude-data/typescript-config/types/acorn"] } }, diff --git a/packages/core/drizzle/0015_black_silhouette.sql b/packages/core/drizzle/0015_black_silhouette.sql new file mode 100644 index 000000000..756e55c7f --- /dev/null +++ b/packages/core/drizzle/0015_black_silhouette.sql @@ -0,0 +1 @@ +ALTER TABLE "latitude"."api_keys" ALTER COLUMN "token" SET DEFAULT gen_random_uuid(); \ No newline at end of file diff --git a/packages/core/drizzle/meta/0015_snapshot.json b/packages/core/drizzle/meta/0015_snapshot.json new file mode 100644 index 000000000..80092fc8d --- /dev/null +++ b/packages/core/drizzle/meta/0015_snapshot.json @@ -0,0 +1,801 @@ +{ + "id": "08d12ce0-a000-42d7-b87d-42c84c028b10", + "prevId": "890e1cea-3457-4c03-a298-e3f6d2bd6fb5", + "version": "7", + "dialect": "postgresql", + "tables": { + "latitude.users": { + "name": "users", + "schema": "latitude", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "latitude.sessions": { + "name": "sessions", + "schema": "latitude", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "latitude", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "latitude.workspaces": { + "name": "workspaces", + "schema": "latitude", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspaces_creator_id_users_id_fk": { + "name": "workspaces_creator_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "schemaTo": "latitude", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "latitude.memberships": { + "name": "memberships", + "schema": "latitude", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "schemaTo": "latitude", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "schemaTo": "latitude", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "latitude.api_keys": { + "name": "api_keys", + "schema": "latitude", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_id_idx": { + "name": "workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_workspace_id_workspaces_id_fk": { + "name": "api_keys_workspace_id_workspaces_id_fk", + "tableFrom": "api_keys", + "tableTo": "workspaces", + "schemaTo": "latitude", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_token_unique": { + "name": "api_keys_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + }, + "latitude.projects": { + "name": "projects", + "schema": "latitude", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_idx": { + "name": "workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "schemaTo": "latitude", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "latitude.commits": { + "name": "commits", + "schema": "latitude", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_commit_order_idx": { + "name": "project_commit_order_idx", + "columns": [ + { + "expression": "merged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "commits_project_id_projects_id_fk": { + "name": "commits_project_id_projects_id_fk", + "tableFrom": "commits", + "tableTo": "projects", + "schemaTo": "latitude", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "commits_uuid_unique": { + "name": "commits_uuid_unique", + "nullsNotDistinct": false, + "columns": [ + "uuid" + ] + } + } + }, + "latitude.document_versions": { + "name": "document_versions", + "schema": "latitude", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "document_uuid": { + "name": "document_uuid", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_content": { + "name": "resolved_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_id": { + "name": "commit_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_uuid_commit_id_idx": { + "name": "document_uuid_commit_id_idx", + "columns": [ + { + "expression": "document_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "commit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_versions_commit_id_commits_id_fk": { + "name": "document_versions_commit_id_commits_id_fk", + "tableFrom": "document_versions", + "tableTo": "commits", + "schemaTo": "latitude", + "columnsFrom": [ + "commit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_document_uuid_commit_id": { + "name": "unique_document_uuid_commit_id", + "nullsNotDistinct": false, + "columns": [ + "document_uuid", + "commit_id" + ] + }, + "unique_path_commit_id_deleted_at": { + "name": "unique_path_commit_id_deleted_at", + "nullsNotDistinct": false, + "columns": [ + "path", + "commit_id", + "deleted_at" + ] + } + } + }, + "latitude.provider_api_keys": { + "name": "provider_api_keys", + "schema": "latitude", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_apikeys_workspace_id_idx": { + "name": "provider_apikeys_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_apikeys_user_id_idx": { + "name": "provider_apikeys_user_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_api_keys_author_id_users_id_fk": { + "name": "provider_api_keys_author_id_users_id_fk", + "tableFrom": "provider_api_keys", + "tableTo": "users", + "schemaTo": "latitude", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "provider_api_keys_workspace_id_workspaces_id_fk": { + "name": "provider_api_keys_workspace_id_workspaces_id_fk", + "tableFrom": "provider_api_keys", + "tableTo": "workspaces", + "schemaTo": "latitude", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_apikeys_token_provider_unique": { + "name": "provider_apikeys_token_provider_unique", + "nullsNotDistinct": false, + "columns": [ + "token", + "provider" + ] + } + } + } + }, + "enums": { + "public.provider": { + "name": "provider", + "schema": "public", + "values": [ + "openai", + "anthropic" + ] + } + }, + "schemas": { + "latitude": "latitude" + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/drizzle/meta/_journal.json b/packages/core/drizzle/meta/_journal.json index 66d954d74..95eb6220b 100644 --- a/packages/core/drizzle/meta/_journal.json +++ b/packages/core/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1722331678854, "tag": "0014_smiling_old_lace", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1722413865871, + "tag": "0015_black_silhouette", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/src/data-access/apiKeys.ts b/packages/core/src/data-access/apiKeys.ts index 71b6045c7..6b9d5c4c6 100644 --- a/packages/core/src/data-access/apiKeys.ts +++ b/packages/core/src/data-access/apiKeys.ts @@ -1,15 +1,16 @@ import { database } from '$core/client' -import { NotFoundError, Result } from '$core/lib' -import { apiKeys } from '$core/schema' +import { NotFoundError, Result, TypedResult } from '$core/lib' +import { ApiKey, apiKeys } from '$core/schema' import { eq } from 'drizzle-orm' -export async function unsafelyGetApiKey( +export async function unsafelyGetApiKeyByToken( { token }: { token: string }, db = database, -) { +): Promise> { const apiKey = await db.query.apiKeys.findFirst({ where: eq(apiKeys.token, token), }) + if (!apiKey) return Result.error(new NotFoundError('API key not found')) return Result.ok(apiKey) diff --git a/packages/core/src/data-access/workspaces.ts b/packages/core/src/data-access/workspaces.ts index fec6cc38a..fb5c47c1b 100644 --- a/packages/core/src/data-access/workspaces.ts +++ b/packages/core/src/data-access/workspaces.ts @@ -1,7 +1,23 @@ import { database } from '$core/client' -import { Commit, commits, projects, workspaces } from '$core/schema' +import { NotFoundError, Result, TypedResult } from '$core/lib' +import { Commit, commits, projects, Workspace, workspaces } from '$core/schema' import { eq, getTableColumns } from 'drizzle-orm' +export async function unsafelyFindWorkspace( + id: number, + db = database, +): Promise> { + const workspace = await db.query.workspaces.findFirst({ + where: eq(workspaces.id, id), + }) + + if (!workspace) { + return Result.error(new NotFoundError('Workspace not found')) + } + + return Result.ok(workspace) +} + export async function findWorkspaceFromCommit(commit: Commit, db = database) { const results = await db .select(getTableColumns(workspaces)) diff --git a/packages/core/src/env.ts b/packages/core/src/env.ts index 035b14d8e..3d2baf09e 100644 --- a/packages/core/src/env.ts +++ b/packages/core/src/env.ts @@ -4,7 +4,8 @@ import z from 'zod' import '@latitude-data/env' export default createEnv({ - skipValidation: process.env.BUILDING_CONTAINER == 'true', + skipValidation: + process.env.BUILDING_CONTAINER == 'true' || process.env.NODE_ENV === 'test', server: { NODE_ENV: z.string(), DATABASE_URL: z.string().url(), diff --git a/packages/core/src/repositories/documentVersionsRepository/index.ts b/packages/core/src/repositories/documentVersionsRepository/index.ts index c3cb93921..4e60600c8 100644 --- a/packages/core/src/repositories/documentVersionsRepository/index.ts +++ b/packages/core/src/repositories/documentVersionsRepository/index.ts @@ -88,7 +88,7 @@ export class DocumentVersionsRepository extends Repository { ) } - return Result.ok(document) + return Result.ok(document!) } catch (err) { return Result.error(err as Error) } diff --git a/packages/core/src/schema/models/apiKeys.ts b/packages/core/src/schema/models/apiKeys.ts index b364c5f43..6b990c3dc 100644 --- a/packages/core/src/schema/models/apiKeys.ts +++ b/packages/core/src/schema/models/apiKeys.ts @@ -1,4 +1,4 @@ -import { InferSelectModel } from 'drizzle-orm' +import { InferSelectModel, relations, sql } from 'drizzle-orm' import { bigint, bigserial, @@ -19,7 +19,7 @@ export const apiKeys = latitudeSchema.table( token: uuid('token') .notNull() .unique() - .$defaultFn(() => crypto.randomUUID()), + .default(sql`gen_random_uuid()`), workspaceId: bigint('workspace_id', { mode: 'number' }) .notNull() .references(() => workspaces.id), @@ -32,4 +32,11 @@ export const apiKeys = latitudeSchema.table( }), ) +export const apiKeyRelations = relations(apiKeys, ({ one }) => ({ + workspace: one(workspaces, { + fields: [apiKeys.workspaceId], + references: [workspaces.id], + }), +})) + export type ApiKey = InferSelectModel diff --git a/packages/core/src/services/apiKeys/create.ts b/packages/core/src/services/apiKeys/create.ts new file mode 100644 index 000000000..b5a80f527 --- /dev/null +++ b/packages/core/src/services/apiKeys/create.ts @@ -0,0 +1,17 @@ +import { database } from '$core/client' +import { Result, Transaction } from '$core/lib' +import { apiKeys, Workspace } from '$core/schema' + +export function createApiKey( + { workspace }: { workspace: Workspace }, + db = database, +) { + return Transaction.call(async (tx) => { + const result = await tx + .insert(apiKeys) + .values({ workspaceId: workspace.id }) + .returning() + + return Result.ok(result[0]!) + }, db) +} diff --git a/packages/core/src/services/apiKeys/index.ts b/packages/core/src/services/apiKeys/index.ts new file mode 100644 index 000000000..f756a8bb3 --- /dev/null +++ b/packages/core/src/services/apiKeys/index.ts @@ -0,0 +1 @@ +export * from './create' diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 13ef50b78..6763fb925 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -5,3 +5,4 @@ export * from './commits' export * from './projects' export * from './workspaces' export * from './providerApiKeys' +export * from './apiKeys' diff --git a/packages/core/src/services/workspaces/create.ts b/packages/core/src/services/workspaces/create.ts index c198f625d..a10c867c4 100644 --- a/packages/core/src/services/workspaces/create.ts +++ b/packages/core/src/services/workspaces/create.ts @@ -8,6 +8,8 @@ import { } from '@latitude-data/core' import { createProject } from '$core/services/projects' +import { createApiKey } from '../apiKeys/create' + export async function createWorkspace( { name, @@ -28,7 +30,9 @@ export async function createWorkspace( await tx .insert(memberships) .values({ workspaceId: newWorkspace.id, userId: creatorId }) + await createProject({ workspaceId: newWorkspace.id }, tx) + await createApiKey({ workspace: newWorkspace }, tx) return Result.ok(newWorkspace) }, db) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f41472591..97dc3892a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,46 @@ importers: specifier: ^5.5.3 version: 5.5.3 + apps/gateway: + dependencies: + '@hono/node-server': + specifier: ^1.12.0 + version: 1.12.0 + '@latitude-data/core': + specifier: workspace:^ + version: link:../../packages/core + '@latitude-data/env': + specifier: workspace:^ + version: link:../../packages/env + hono: + specifier: ^4.5.3 + version: 4.5.3 + devDependencies: + '@latitude-data/eslint-config': + specifier: workspace:^ + version: link:../../tools/eslint + '@latitude-data/typescript-config': + specifier: workspace:^ + version: link:../../tools/typescript + eslint: + specifier: '8' + version: 8.57.0 + jet-paths: + specifier: ^1.0.6 + version: 1.0.6 + tsx: + specifier: ^4.16.2 + version: 4.16.2 + typescript: + specifier: ^5.5.4 + version: 5.5.4 + vite-tsconfig-paths: + specifier: ^4.3.2 + version: 4.3.2(typescript@5.5.4) + vitest: + specifier: ^2.0.4 + version: 2.0.4 + apps/web: dependencies: '@elastic/elasticsearch': @@ -1537,6 +1577,11 @@ packages: resolution: {integrity: sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA==} dev: false + /@hono/node-server@1.12.0: + resolution: {integrity: sha512-e6oHjNiErRxsZRZBmc2KucuvY3btlO/XPncIpP2X75bRdTilF9GLjm3NHvKKunpJbbJJj31/FoPTksTf8djAVw==} + engines: {node: '>=18.14.1'} + dev: false + /@humanwhocodes/config-array@0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -3901,12 +3946,27 @@ packages: tinyrainbow: 1.2.0 dev: true + /@vitest/expect@2.0.4: + resolution: {integrity: sha512-39jr5EguIoanChvBqe34I8m1hJFI4+jxvdOpD7gslZrVQBKhh8H9eD7J/LJX4zakrw23W+dITQTDqdt43xVcJw==} + dependencies: + '@vitest/spy': 2.0.4 + '@vitest/utils': 2.0.4 + chai: 5.1.1 + tinyrainbow: 1.2.0 + dev: true + /@vitest/pretty-format@2.0.3: resolution: {integrity: sha512-URM4GLsB2xD37nnTyvf6kfObFafxmycCL8un3OC9gaCs5cti2u+5rJdIflZ2fUJUen4NbvF6jCufwViAFLvz1g==} dependencies: tinyrainbow: 1.2.0 dev: true + /@vitest/pretty-format@2.0.4: + resolution: {integrity: sha512-RYZl31STbNGqf4l2eQM1nvKPXE0NhC6Eq0suTTePc4mtMQ1Fn8qZmjV4emZdEdG2NOWGKSCrHZjmTqDCDoeFBw==} + dependencies: + tinyrainbow: 1.2.0 + dev: true + /@vitest/runner@1.6.0: resolution: {integrity: sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==} dependencies: @@ -3922,6 +3982,13 @@ packages: pathe: 1.1.2 dev: true + /@vitest/runner@2.0.4: + resolution: {integrity: sha512-Gk+9Su/2H2zNfNdeJR124gZckd5st4YoSuhF1Rebi37qTXKnqYyFCd9KP4vl2cQHbtuVKjfEKrNJxHHCW8thbQ==} + dependencies: + '@vitest/utils': 2.0.4 + pathe: 1.1.2 + dev: true + /@vitest/snapshot@1.6.0: resolution: {integrity: sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==} dependencies: @@ -3938,6 +4005,14 @@ packages: pathe: 1.1.2 dev: true + /@vitest/snapshot@2.0.4: + resolution: {integrity: sha512-or6Mzoz/pD7xTvuJMFYEtso1vJo1S5u6zBTinfl+7smGUhqybn6VjzCDMhmTyVOFWwkCMuNjmNNxnyXPgKDoPw==} + dependencies: + '@vitest/pretty-format': 2.0.4 + magic-string: 0.30.10 + pathe: 1.1.2 + dev: true + /@vitest/spy@1.6.0: resolution: {integrity: sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==} dependencies: @@ -3950,6 +4025,12 @@ packages: tinyspy: 3.0.0 dev: true + /@vitest/spy@2.0.4: + resolution: {integrity: sha512-uTXU56TNoYrTohb+6CseP8IqNwlNdtPwEO0AWl+5j7NelS6x0xZZtP0bDWaLvOfUbaYwhhWp1guzXUxkC7mW7Q==} + dependencies: + tinyspy: 3.0.0 + dev: true + /@vitest/utils@1.6.0: resolution: {integrity: sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==} dependencies: @@ -3968,6 +4049,15 @@ packages: tinyrainbow: 1.2.0 dev: true + /@vitest/utils@2.0.4: + resolution: {integrity: sha512-Zc75QuuoJhOBnlo99ZVUkJIuq4Oj0zAkrQ2VzCqNCx6wAwViHEh5Fnp4fiJTE9rA+sAoXRf00Z9xGgfEzV6fzQ==} + dependencies: + '@vitest/pretty-format': 2.0.4 + estree-walker: 3.0.3 + loupe: 3.1.1 + tinyrainbow: 1.2.0 + dev: true + /abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} dev: false @@ -6124,6 +6214,10 @@ packages: slash: 4.0.0 dev: true + /globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + dev: true + /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: @@ -6189,6 +6283,11 @@ packages: engines: {node: '>=8'} dev: true + /hono@4.5.3: + resolution: {integrity: sha512-r26WwwbKD3BAYdfB294knNnegNda7VfV1tVn66D9Kvl9WQTdrR+5eKdoeaQNHQcC3Gr0KBikzAtjd6VsRGVSaw==} + engines: {node: '>=16.0.0'} + dev: false + /hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} dev: true @@ -6567,6 +6666,10 @@ packages: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + /jet-paths@1.0.6: + resolution: {integrity: sha512-LqjN1QY4ixVaouzLt0whXPKmVQjiR0AOw0sJNEt+fO2M0b4lYI5VuMZYCS08BLEgyJIH3mBX3t8m82TwblxIhQ==} + dev: true + /jiti@1.21.6: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true @@ -8648,6 +8751,19 @@ packages: /ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + /tsconfck@3.1.1(typescript@5.5.4): + resolution: {integrity: sha512-00eoI6WY57SvZEVjm13stEVE90VkEdJAFGgpFLTsZbJyW/LwFQ7uQxJHWpZ2hzSWgCPKc9AnBnNP+0X7o3hAmQ==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + dependencies: + typescript: 5.5.4 + dev: true + /tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} dependencies: @@ -8829,6 +8945,12 @@ packages: engines: {node: '>=14.17'} hasBin: true + /typescript@5.5.4: + resolution: {integrity: sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + /ufo@1.5.3: resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==} dev: true @@ -9022,6 +9144,43 @@ packages: - terser dev: true + /vite-node@2.0.4: + resolution: {integrity: sha512-ZpJVkxcakYtig5iakNeL7N3trufe3M6vGuzYAr4GsbCTwobDeyPJpE4cjDhhPluv8OvQCFzu2LWp6GkoKRITXA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + dependencies: + cac: 6.7.14 + debug: 4.3.5 + pathe: 1.1.2 + tinyrainbow: 1.2.0 + vite: 5.3.3 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + + /vite-tsconfig-paths@4.3.2(typescript@5.5.4): + resolution: {integrity: sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + dependencies: + debug: 4.3.5 + globrex: 0.1.2 + tsconfck: 3.1.1(typescript@5.5.4) + transitivePeerDependencies: + - supports-color + - typescript + dev: true + /vite@5.3.3: resolution: {integrity: sha512-NPQdeCU0Dv2z5fu+ULotpuq5yfCS1BzKUIPhNbP3YBfAMGJXbt2nS+sbTFu+qchaqWTD+H3JK++nRwr6XIcp6A==} engines: {node: ^18.0.0 || >=20.0.0} @@ -9259,6 +9418,60 @@ packages: - terser dev: true + /vitest@2.0.4: + resolution: {integrity: sha512-luNLDpfsnxw5QSW4bISPe6tkxVvv5wn2BBs/PuDRkhXZ319doZyLOBr1sjfB5yCEpTiU7xCAdViM8TNVGPwoog==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.0.4 + '@vitest/ui': 2.0.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@ampproject/remapping': 2.3.0 + '@vitest/expect': 2.0.4 + '@vitest/pretty-format': 2.0.4 + '@vitest/runner': 2.0.4 + '@vitest/snapshot': 2.0.4 + '@vitest/spy': 2.0.4 + '@vitest/utils': 2.0.4 + chai: 5.1.1 + debug: 4.3.5 + execa: 8.0.1 + magic-string: 0.30.10 + pathe: 1.1.2 + std-env: 3.7.0 + tinybench: 2.8.0 + tinypool: 1.0.0 + tinyrainbow: 1.2.0 + vite: 5.3.3 + vite-node: 2.0.4 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + /w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'}