From 292e661c03fc604ff84523ce23c7d6dbc5fae9f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Fri, 31 Jul 2026 18:28:55 -0400 Subject: [PATCH] feat: add Files gateway asset index plan, fix marquee spacing, align table formatting --- .plans/16-documentation-site.md | 328 +++++++++++++ .plans/files-gateway-asset-index.md | 450 ++++++++++++++++++ README.md | 12 +- apps/api/src/shared/files.ts | 2 +- apps/api/src/shared/middleware.ts | 8 +- apps/api/src/shared/types.ts | 5 +- apps/api/src/shared/utils.ts | 5 + .../components/technology-marquee.astro | 4 +- apps/web/src/shared/components/layout.astro | 110 +++-- 9 files changed, 861 insertions(+), 63 deletions(-) create mode 100644 .plans/16-documentation-site.md create mode 100644 .plans/files-gateway-asset-index.md diff --git a/.plans/16-documentation-site.md b/.plans/16-documentation-site.md new file mode 100644 index 00000000..c12d2fdf --- /dev/null +++ b/.plans/16-documentation-site.md @@ -0,0 +1,328 @@ +# Plan 16 — Init documentation site + +**Status:** Pending +**Size:** L + +Turn `apps/docs` into the public Init documentation site and the polished, +replaceable Starlight example that ships when a scaffold owner selects the docs +workspace. Present the consumer-facing material already authored under root `docs/` +instead of maintaining a second copy inside the application workspace, and bring the +visual system into the same family as `apps/web`. + +The result should preserve Starlight's documentation strengths—accessible navigation, +search, table of contents, code presentation, localization, and static output—while +adopting Init's typography, neutral palette, sky accent, wordmark, and restrained visual +character. It should feel related to the marketing site without turning documentation +pages into another landing page. + +## Decision + +Use one documentation-site workspace with two roles: + +- In the template repository, it presents Init's own consumer documentation. +- In a scaffolded project, it is a complete documentation-site example that renders + the scaffold owner's inherited and subsequently authored root documentation. +- Root `docs/` owns authored Markdown and MDX content. +- `apps/docs` owns the presentation module: content loading, routes, navigation, + search, metadata, localization behavior, styles, and static assets. +- Do not copy root documents into `apps/docs/src/content/docs`, create a second docs + tree, or import runtime code from `apps/web`. + +Record this upstream choice in +`docs/template/adr/0006-docs-dual-role.md`, following the decision routing in +`docs/agents/domain.md`. The ADR must distinguish the template maintainer's choice from +decisions made later by a scaffold owner. + +## Content seam + +The content collection is the seam between authored repository documentation and the +Starlight presentation module. Keep its interface small: + +- publish supported Markdown and MDX files from root `docs/`; +- derive stable routes from paths relative to `docs/`; +- validate page metadata through Starlight's `docsSchema()`; +- explicitly include consumer-facing content and exclude maintainer-only material. + +Implement the adapter in `apps/docs/src/content.config.ts` with Astro's `glob()` loader +instead of Starlight's fixed `docsLoader()`, whose implementation only reads +`src/content/docs`. Set the loader base to root `docs/` and use inclusion patterns that +publish: + +| Source | Route | Navigation | +| ------------------------------- | ------------------- | -------------------- | +| `docs/index.mdx` | `/` | Overview | +| `docs/getting-started.md` | `/getting-started/` | Start Here | +| Other top-level consumer guides | `//` | Guides | +| `docs/architecture/**` | `/architecture/**` | Architecture | +| `docs/es/**` | `/es/**` | Spanish localization | + +Do not publish or index: + +- `docs/agents/**`, which configures repository agents and issue workflows; +- `docs/template/**`, which contains upstream governance and research and is removed + through the internal cleanup path during setup; +- a future `docs/adr/**` by default, because application-owner decisions should not + become public documentation without an explicit publishing choice. + +This inclusion policy must be visible in one loader configuration rather than repeated +through per-page flags. A content change should become visible after editing its root +document and rebuilding the docs workspace; no synchronization command should exist. + +## 1. Move the example homepage into canonical documentation + +Create `docs/index.mdx` as the Starlight splash homepage and remove the placeholder +homepage under `apps/docs/src/content/docs/index.mdx`. + +The homepage should include: + +- the Init wordmark and concise documentation-specific introduction; +- the scaffold command `bun create metaideas/init my-app`; +- a primary action to Getting Started and a secondary GitHub action; +- cards leading to setup, workspace selection, project generators, and architecture; +- a compact statement that Init is a selectable TypeScript monorepo template, using + the vocabulary in `CONTEXT.md`; +- no marketing marquee, oversized animated headline, or duplicated marketing footer. + +Use Starlight's native hero, card, and code facilities where they satisfy the design. +Add a local Astro module only when it hides meaningful reusable behavior rather than +wrapping one Starlight element. + +Move or recreate the Spanish homepage as `docs/es/index.mdx`. Keep English as the +canonical authoring language for the existing guides. Starlight should show its normal +fallback notice for untranslated content; translating every guide is not required by +this plan. + +Delete `apps/docs/src/shared/components/localized-greeting.astro` and remove the +`docs_example_greeting` catalog messages once nothing uses them. + +## 2. Make existing Markdown valid Starlight content + +Add minimal frontmatter to each published root document: + +- `title`; +- a useful `description` suitable for search and metadata; +- `sidebar.order` or `sidebar.label` only where route order or display text is not + obvious; +- page-specific table-of-contents or edit-link overrides only when necessary. + +Keep documents useful when read directly on GitHub. Do not replace meaningful H1s with +site-only presentation or introduce MDX imports into ordinary guides unless the result +materially improves comprehension. + +Review internal relative links after the content base changes. Links must work both in +GitHub source views and in generated Starlight routes. Prefer normal relative Markdown +links and let Astro resolve them; do not hard-code the production docs hostname into +cross-document links. + +## 3. Establish the information architecture + +Replace the one-page sidebar in `apps/docs/astro.config.ts` with a deliberate manual +top-level structure: + +1. **Start Here** + - Overview + - Getting Started + - Development +2. **Build** + - Project Generators + - Package Guidance + - Internationalization + - Template Commands +3. **Architecture** + - Project Structure + - Backend Topology + - File Service + - Desktop Behavior +4. **Elsewhere** + - `init.now` + - GitHub + +The manual groups define the reader journey and stable labels. Autogeneration may be +used inside a group only if it preserves this order and does not expose excluded +directories. Keep route slugs aligned with source paths so edit links and repository +navigation remain predictable. + +Configure Starlight search over every published page. Verify that excluded agent, +template, research, and ADR content cannot appear in Pagefind results. + +## 4. Bring the visual system closer to `apps/web` + +Expand `apps/docs/src/shared/styles/globals.css` through Starlight's supported custom CSS +and design tokens. Preserve Starlight's layout and accessible interaction behavior. + +Adopt the marketing site's visual language: + +- light background near `#fafafa` and dark background `#080a0d`; +- neutral navigation and panel surfaces with quiet hairlines; +- sky as the signal/accent color; +- the same muted-text hierarchy and restrained shadows; +- Geist Mono for the wordmark, code, and small technical labels; +- compatible radii, focus rings, and light/dark contrast; +- subtle radial glow or grid detail on the splash homepage and header, not behind long + reading surfaces; +- Init favicon, logo, and social-preview artwork sized for documentation use. + +Style the Starlight primitives that readers encounter most: + +- site title and header; +- search trigger and modal; +- sidebar links and current-page state; +- headings, inline code, and expressive code blocks; +- cards, asides, and link buttons; +- table of contents and previous/next navigation; +- locale and theme controls. + +Prefer Starlight configuration and CSS variables before overriding a Starlight module. +If the built-in title configuration cannot produce the desired `init / docs` wordmark, +add one local `SiteTitle.astro` override while continuing to use the default header, +search, theme, and language modules. + +Do not import from `apps/web`; app-to-app imports are forbidden. Keep the small docs +assets locally owned. Do not introduce a brand package in this plan unless a third +independent caller creates a real seam for shared branding. + +Honor reduced motion, preserve visible keyboard focus, maintain readable contrast in +both themes, and keep touch targets usable on narrow screens. + +## 5. Complete public-site metadata and behavior + +Configure a canonical `site` URL so Starlight's sitemap is generated without the +current warning. Use the intended Init docs hostname as the production default and +allow scaffold owners to replace it through the same environment-tooling conventions +used by `apps/web`. + +Add or configure: + +- site title and description; +- canonical URLs; +- Init favicon; +- Open Graph and social-preview metadata; +- theme color for light and dark modes; +- GitHub social link; +- edit links targeting the canonical source files under root `docs/`; +- last-updated information when Git history is available; +- a useful static 404 page consistent with the docs theme. + +The production build must require no external service or account. Pagefind remains the +local static search adapter; do not add hosted search in this plan. + +## 6. Connect the web and docs surfaces + +Add a clear documentation link from `apps/web` and a return link from the docs site to +`init.now`. Keep the two application workspaces independently buildable and do not +share imports between them. + +Centralize site name, GitHub URL, marketing URL, and documentation URL inside the +appropriate workspace's shared constants so metadata and navigation within that +workspace cannot drift across multiple call sites. Duplicating a few deployment URLs +across independently selectable application workspaces is preferable to creating a +shallow shared package for constants with only two callers. + +Preserve the user's existing uncommitted work in `apps/web`, especially changes under +the landing hero and ship-target modules. The docs link should be a narrow integration +change that does not rewrite those files unnecessarily. + +## 7. Document customization and scaffold behavior + +Expand `apps/docs/README.md` with: + +- root development and targeted build commands; +- the root `docs/` → Starlight content ownership model; +- the published/excluded directory policy; +- where to change the wordmark, colors, site URL, navigation, and metadata; +- how to add a page or localized page; +- the lack of external build-time and runtime services. + +Update the root README to link to the deployed documentation site while retaining +useful source links where appropriate. + +Verify the template's two selection outcomes in disposable scaffolds: + +- keeping the docs workspace retains the complete site and renders the scaffolded + project's root documentation; +- omitting the docs workspace removes `apps/docs` while leaving the root Markdown + documentation available to the scaffold owner; +- removal of `docs/template/`, issue-tracker instructions, and other internal cleanup + paths leaves no dangling routes, imports, or links in the built site. + +Do not add `docs/` itself to cleanup paths and do not make the documentation site +load-bearing for reading repository guidance. + +## Verification + +```sh +bun run format +bun run check +bun run analyze +bun run check:monorepo +bun test +bun run build --filter=docs +bun run build --filter=web +``` + +Run the targeted builds after content and navigation changes rather than waiting until +the end. Run `bun run analyze` after changing dependencies, imports, or exports. + +Manual verification: + +- Check the splash page and representative long-form pages at narrow mobile, tablet, + laptop, and wide desktop widths. +- Verify keyboard navigation, skip link, visible focus, search, copy buttons, and + previous/next navigation. +- Verify light, dark, system, and reduced-motion behavior. +- Search for terms from every consumer-facing guide and confirm excluded maintainer + documents never appear. +- Verify English and Spanish routes, locale switching, and fallback notices. +- Verify source-relative links, heading anchors, edit links, and last-updated metadata. +- Inspect the built title, description, canonical URL, favicon, sitemap, Open Graph + image, and 404 page. +- Run `bun template setup` in disposable scaffolds with `docs` retained and omitted. + +## Acceptance criteria + +- Root `docs/` is the only authored documentation source; existing guides are not + copied under `apps/docs`. +- The docs workspace publishes all consumer-facing top-level and architecture + documents with stable routes, coherent navigation, and Pagefind search. +- `docs/agents/**`, `docs/template/**`, and project ADRs are absent from public routes + and search unless a future explicit decision changes the inclusion policy. +- The site visibly belongs to the same design family as `apps/web` while preserving a + calm, readable documentation surface. +- The homepage provides direct paths into setup, building, and architecture and uses + the project's established vocabulary. +- Theme, locale, search, keyboard, responsive, and reduced-motion behavior remain + accessible. +- Canonical, sitemap, favicon, social, edit-link, and 404 metadata are complete. +- `bun run build --filter=docs` completes without the missing-`site` sitemap warning + and without external services. +- `apps/docs` remains independently selectable, buildable, replaceable, and removable + through normal template setup. +- Keeping or omitting the workspace in a disposable scaffold produces no dangling + content, imports, cleanup-path references, or dependency-graph errors. + +## Out of scope + +- Translating every existing guide into Spanish. +- Publishing template ADRs, research, agent instructions, or project ADRs. +- Hosted search, analytics, authentication, comments, or a documentation backend. +- Rebuilding Starlight's layout, search, theme, or locale modules from scratch. +- A shared brand package created solely for `apps/web` and `apps/docs`. +- A second documentation source tree, content synchronization command, or independent + lockfile. +- Broad rewrites of the existing technical documentation unrelated to presentation, + metadata, navigation, or broken links discovered during migration. + +## Decisions + +- Source content: root `docs/` +- Presentation workspace: `apps/docs` +- Framework: Astro Starlight with static output and local Pagefind search +- Content adapter: Astro `glob()` loader validated by Starlight `docsSchema()` +- Published content: consumer guides, architecture, and localized public pages +- Excluded content: agent instructions, template governance/research, and project ADRs +- Visual reference: the existing `apps/web` palette, typography, assets, and interaction + character +- Localization: English canonical content with a Spanish homepage and Starlight + fallbacks until guides are translated +- Scaffold behavior: retain the complete example when `docs` is selected; leave root + Markdown intact when it is omitted diff --git a/.plans/files-gateway-asset-index.md b/.plans/files-gateway-asset-index.md new file mode 100644 index 00000000..0f4b628d --- /dev/null +++ b/.plans/files-gateway-asset-index.md @@ -0,0 +1,450 @@ +# Authenticated Files gateway and asset index + +## Status + +Proposed. + +## Objective + +Exercise one complete authenticated file lifecycle through the API application: + +1. an authenticated user uploads a file through Files SDK; +2. the object is written to the configured S3-compatible bucket; +3. a Files SDK `onAction` hook records the object's ownership and metadata in the + `storage.assets` table; +4. another user cannot access the object; and +5. deleting the object marks the asset record as deleted. + +Keep the Files SDK gateway at the versionless `/files` transport seam. Reserve +`/v1/assets` for a future application-owned HTTP interface over asset IDs, ownership, +organizations, metadata, and references. + +## Acceptance criteria + +- The Files SDK gateway is mounted at `/files`, not `/v1/files`. +- `apps/api/src/routes/files.ts` owns the Files SDK router and authorization policy. +- The existing Files SDK singleton in `apps/api/src/shared/files.ts` owns the inline + asset-index hook. +- The `/v1` router contains only application-owned HTTP routes. +- Hono's existing `contextStorage()` and `requireSession` middleware provide the + authenticated session, database, and logger to Files SDK callbacks. +- Shared `context()` and `AuthenticatedAppContext` definitions provide typed access to + that request state outside ordinary Hono handlers. +- The route does not perform a second Better Auth session lookup inside `authorize`. +- Successful upload completion creates or updates one `storage.assets` row owned by + the authenticated user. +- Successful deletion marks the matching asset row as `deleted`. +- `onAction` dispatches successful events to local `handleUpload` and `handleDelete` + functions in the singleton module; there is no persistence service or custom Files + SDK plugin. +- Repeated upload-completion events are idempotent through the existing unique index + on `(bucket, key)`. +- Database failures from the fire-and-forget hook are caught and logged without + producing unhandled promise rejections. +- Formatting, static checks, dependency analysis, and relevant build verification + pass. + +## Decisions + +### Treat `/files` as transport infrastructure + +The Files SDK gateway's wire format is controlled by Files SDK. It is transport and +protocol plumbing, like Better Auth, tRPC, and Inngest, rather than an +application-owned REST resource. + +Use this route split: + +| Route | Ownership | +| ------------ | ------------------------------------------ | +| `/files` | Files SDK transport gateway | +| `/auth` | Better Auth transport gateway | +| `/trpc` | tRPC transport gateway | +| `/workflows` | Inngest transport gateway | +| `/v1/assets` | Future application-owned asset interface | +| `/v1/me` | Application-owned authenticated HTTP route | + +Do not add `/v1/assets` as part of this slice. Its route is reserved for later domain +behavior and should not proxy Files SDK operations. + +### Use the database as an eventually consistent asset index + +Object storage remains the source of truth for whether bytes exist. The assets table +tracks which authenticated user owns each stored object and provides a stable asset ID +for future references from other tables. + +Files SDK's `onAction` hook is observational and fire-and-forget: Files SDK does not +await a promise returned by the hook, and a hook failure cannot fail the storage +operation. Therefore: + +- the upload response does not guarantee that the asset row is already visible; +- every hook-owned promise must have an explicit rejection handler; +- stronger delivery guarantees or reconciliation are follow-up work, not hidden inside + this first slice. + +### Dispatch `onAction` events to local handlers + +Keep the persistence behavior local to the Files singleton. Do not introduce an +`assetPersistence` plugin, repository wrapper, or callback adapter. + +Use a switch to keep the hook concise. Single upload and head events call +`handleUpload`; delete events normalize their single or bulk keys and call +`handleDelete`. These handlers remain private functions in `shared/files.ts` and +execute the Drizzle operations directly. + +### Keep the current asset schema + +The existing `storage.assets` table already contains the fields required by this +slice: + +- `(bucket, key)` provides storage identity and idempotency; +- `uploaderId` records the authenticated owner for the current model; +- `provider`, `mimeType`, `size`, `name`, and `metadata` describe the object; and +- `status` represents `available` and `deleted` lifecycle states. + +Do not rename `uploaderId`, remove organization support, or otherwise redesign the +schema in this change. Those decisions can be made when the application-owned +`/v1/assets` interface is designed. + +### Observe upload and head completion + +Handle successful `upload` events and successful `head` events. Direct/proxied uploads +can produce an upload result, while completion of a direct or presigned upload can be +confirmed through a follow-up `head`. The `(bucket, key)` conflict target makes both +paths idempotent. + +Do not support copy or move until their destination-ownership behavior is explicitly +defined. Do not build the index from list results. + +## Implementation plan + +### 1. Move the Files SDK gateway out of `/v1` + +Move: + +```text +apps/api/src/routes/v1/files.ts +``` + +to: + +```text +apps/api/src/routes/files.ts +``` + +Add the route to `apps/api/src/routes/index.ts`: + +```diff ++import filesRoutes from "#routes/files.ts" + import healthRoutes from "#routes/health.ts" + import trpcRoutes from "#routes/trpc.ts" + import v1Routes from "#routes/v1/index.ts" + import workflowRoutes from "#routes/workflows.ts" + + export const router = app + // Keep the existing root and OpenAPI handlers. + .route("/health", healthRoutes) ++ .route("/files", filesRoutes) + .route("/workflows", workflowRoutes) + .route("/trpc", trpcRoutes) + .route("/v1", v1Routes) +``` + +Remove the transport route from `apps/api/src/routes/v1/index.ts`: + +```diff +-import filesRoutes from "#routes/v1/files.ts" + import { m } from "#shared/internationalization/messages.js" + + export default factory + .createApp() +- .route("/files", filesRoutes) + .get( + "/hello", + // Keep the existing handler unchanged. + ) + .get("/me", requireSession, (c) => c.json(c.var.session.user)) +``` + +### 2. Add shared typed context access + +Add the authenticated refinement to `apps/api/src/shared/types.ts`: + +```ts +export type AuthenticatedAppContext = DeepMerge +``` + +Restore the context-storage helper in `apps/api/src/shared/utils.ts`, allowing callers +to select a refined context while defaulting to `AppContext`: + +```ts +export function context() { + return getContext() +} +``` + +Make `requireSession` consume the shared authenticated type instead of declaring the +same merge inline: + +```ts +export const requireSession = createMiddleware(async (c, next) => { + // Keep the existing middleware implementation. +}) +``` + +### 3. Add `onAction` directly to the Files singleton + +Replace `apps/api/src/shared/files.ts` with this proposed implementation: + +```ts +import { assets } from "@init/db/schema" +import * as z from "@init/utils/schema" +import { and, eq, inArray } from "drizzle-orm" +import type { DeleteManyResult, StoredFile, UploadResult } from "files-sdk" +import { createFiles } from "files-sdk" +import { bunS3 } from "files-sdk/bun-s3" +import { contentType } from "files-sdk/content-type" +import { signedUrlPolicy } from "files-sdk/signed-url-policy" +import { validation } from "files-sdk/validation" +import env from "#shared/env.ts" +import type { AuthenticatedAppContext } from "#shared/types.ts" +import { context } from "#shared/utils.ts" + +export const FILES_MAX_UPLOAD_SIZE = 10 * 1024 * 1024 +export const FILES_MAX_URL_AGE = 15 * 60 + +export const files = createFiles({ + adapter: bunS3({ + accessKeyId: env.S3_ACCESS_KEY_ID, + bucket: env.S3_BUCKET, + endpoint: env.S3_ENDPOINT, + region: env.S3_REGION, + secretAccessKey: env.S3_SECRET_ACCESS_KEY, + virtualHostedStyle: !env.S3_ENDPOINT, + }), + hooks: { + onAction(event) { + if (event.status !== "success") return + + switch (event.type) { + case "upload": + if (event.key) handleUpload(event.key, event.result as UploadResult) + break + case "head": + if (event.key) handleUpload(event.key, event.result as StoredFile) + break + case "delete": { + const keys = event.key ? [event.key] : (event.result as DeleteManyResult).deleted + + handleDelete(keys) + break + } + } + }, + }, + plugins: [ + signedUrlPolicy({ + maxExpiresIn: FILES_MAX_URL_AGE, + maxUploadSize: FILES_MAX_UPLOAD_SIZE, + }), + validation({ + allowedTypes: ["image/*", "application/pdf"], + key: (key) => + z + .string() + .regex(/^[\w.-]+(?:\/[\w.-]+)*$/u) + .refine((value) => + value.split("/").every((segment) => segment !== "." && segment !== "..") + ) + .safeParse(key).success, + maxSize: FILES_MAX_UPLOAD_SIZE, + minSize: 1, + }), + contentType({ onMismatch: "reject" }), + ], +}) + +function handleUpload(key: string, file: UploadResult | StoredFile) { + const ctx = context() + const mimeType = "contentType" in file ? file.contentType : file.type + const name = "name" in file ? file.name : (key.split("/").at(-1) ?? key) + + void ctx.var.db + .insert(assets) + .values({ + bucket: env.S3_BUCKET, + key, + metadata: file.etag ? { etag: file.etag } : null, + mimeType, + name, + provider: "bun-s3", + size: file.size, + status: "available", + uploaderId: ctx.var.session.user.id, + }) + .onConflictDoUpdate({ + target: [assets.bucket, assets.key], + set: { + errorMessage: null, + metadata: file.etag ? { etag: file.etag } : null, + mimeType, + name, + size: file.size, + status: "available", + updatedAt: new Date(), + }, + }) + .catch((error: unknown) => { + ctx.var.logger.error(`Failed to record asset: ${String(error)}`) + }) +} + +function handleDelete(keys: string[]) { + if (keys.length === 0) return + + const ctx = context() + + void ctx.var.db + .update(assets) + .set({ + status: "deleted", + updatedAt: new Date(), + }) + .where( + and( + eq(assets.bucket, env.S3_BUCKET), + inArray(assets.key, keys), + eq(assets.uploaderId, ctx.var.session.user.id) + ) + ) + .catch((error: unknown) => { + ctx.var.logger.error(`Failed to mark asset as deleted: ${String(error)}`) + }) +} +``` + +The singleton is still constructed once at module initialization. Only the `onAction` +callback reads Hono context, and it does so when Files SDK invokes the singleton during +an authenticated request. + +This intentionally makes action-producing use of this singleton request-bound: a +future caller that invokes it outside Hono context would not have a session to assign +as `uploaderId` and must use a separately configured Files instance or establish an +equivalent ownership context. + +### 4. Implement the complete versionless gateway route + +Use this proposed implementation for `apps/api/src/routes/files.ts`: + +```ts +import { createFilesRouter } from "files-sdk/api" +import { createRouteHandler } from "files-sdk/hono" +import env from "#shared/env.ts" +import { files, FILES_MAX_UPLOAD_SIZE, FILES_MAX_URL_AGE } from "#shared/files.ts" +import { requireSession } from "#shared/middleware.ts" +import type { AuthenticatedAppContext } from "#shared/types.ts" +import { context, factory } from "#shared/utils.ts" + +const router = createFilesRouter({ + allowedOrigins: env.ALLOWED_API_ORIGINS, + authorize: () => { + const ctx = context() + + return { + disposition: "attachment", + keyPrefix: `users/${ctx.var.session.user.id}/`, + maxExpiresIn: FILES_MAX_URL_AGE, + maxResults: 100, + } + }, + files, + maxListLimit: 100, + maxSearchResults: 100, + maxUploadSize: FILES_MAX_UPLOAD_SIZE, + operations: [ + "capabilities", + "delete", + "download", + "exists", + "head", + "list", + "search", + "signedUploadUrl", + "upload", + "url", + ], + secret: env.FILES_API_SECRET, +}) + +export default factory.createApp().all("/", requireSession, createRouteHandler(router)) +``` + +The installed Files SDK router applies `keyPrefix` before invoking `files.upload`, +`files.head`, and `files.delete`. The hook therefore observes storage keys such as +`users//avatar.png`, while gateway responses remove the prefix for clients. + +The singleton's `event.key` guards intentionally ignore bulk head events because reads +are not the asset-creation seam. Bulk deletion uses `DeleteManyResult.deleted`, so only +keys that were successfully removed change status. + +`requireSession` runs before `createRouteHandler`, allowing both `authorize` and +`onAction` to read the session from Hono context storage. Remove the old direct `auth` +import, `auth.api.getSession()` call, and `FilesError` authorization branch. + +### 5. Declare the direct Drizzle dependency + +Because the shared Files module constructs Drizzle expressions itself, add the +installed Drizzle version as a direct dependency of `apps/api`: + +```diff + "dependencies": { + "@init/workflows": "workspace:*", + "@scalar/hono-api-reference": "^0.9.48", + "@trpc/server": "11.8.1", ++ "drizzle-orm": "0.45.2", + "files-sdk": "2.2.2", + "hono": "4.12.32" + } +``` + +### 6. Limit the initial operation surface + +The concrete `operations` list above permits upload, direct/presigned upload, metadata, +listing, download, URL, and deletion. It excludes `copy` and `move` because the hook +does not update destination ownership, and excludes versioning and trash operations +because the configured Files instance does not install those plugins. + +### 7. Verify the change + +Run the repository-managed static checks: + +```sh +bun run format +bun run check +bun run analyze +bun run check:monorepo +``` + +The API workspace currently has no build task, so do not add or invoke one as part of +this change. + +## Explicitly out of scope + +- Implementing `/v1/assets`. +- Renaming `uploaderId` or redesigning the asset schema. +- Making the asset table the source of truth for object existence. +- Transactional guarantees between object storage and Postgres. +- A reconciliation worker for missing or stale asset rows. +- Copy and move ownership semantics. +- Organization-owned asset authorization. +- Asset labels, references, transformations, or processing pipelines. + +## Follow-up work + +- Design the application-owned `/v1/assets` interface around asset IDs rather than raw + storage keys. +- Decide whether ownership should become `userId`, a polymorphic owner, or separate + user/organization ownership records. +- Add reconciliation if production requirements demand repair of missed hook writes. +- Define database behavior for copy, move, bulk operations, and failed uploads. +- Add domain references from future records to `assets.id` instead of duplicating + bucket keys. diff --git a/README.md b/README.md index 0bf7a4e8..56348993 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,15 @@ A modern monorepo template for whatever you build next. -See the template in action at [init.now](https://init.now). The public site is the same -polished Astro marketing app included when a scaffold keeps the `web` workspace. +## What's included -Init distributes optional copy-once code through local, snapshot-matched Turbo -generators. The site is not a hosted recipe catalog or project updater. +- Fullstack application using [TanStack Start](https://tanstack.com/start) +- Marketing site and blog using [Astro](https://astro.build/) +- Documentation site using [Astro](https://astro.build/) with [Starlight](https://starlight.astro.build/) +- Mobile application using [Expo](https://expo.dev/) +- API with [Hono](https://hono.dev/) and [TRPC](https://trpc.io/) +- Desktop application using [Tauri](https://tauri.app/) +- Browser extension using [WXT](https://wxt.dev/) ## Documentation diff --git a/apps/api/src/shared/files.ts b/apps/api/src/shared/files.ts index 724aa7ca..261d1ba8 100644 --- a/apps/api/src/shared/files.ts +++ b/apps/api/src/shared/files.ts @@ -13,7 +13,7 @@ export const files = createFiles({ adapter: bunS3({ accessKeyId: env.S3_ACCESS_KEY_ID, bucket: env.S3_BUCKET, - ...(env.S3_ENDPOINT ? { endpoint: env.S3_ENDPOINT } : {}), + endpoint: env.S3_ENDPOINT, region: env.S3_REGION, secretAccessKey: env.S3_SECRET_ACCESS_KEY, virtualHostedStyle: !env.S3_ENDPOINT, diff --git a/apps/api/src/shared/middleware.ts b/apps/api/src/shared/middleware.ts index bff607ee..e236b3d4 100644 --- a/apps/api/src/shared/middleware.ts +++ b/apps/api/src/shared/middleware.ts @@ -1,12 +1,10 @@ -import type { DeepMerge } from "@init/utils/type" import { findIp } from "@arcjet/ip" import { rateLimiter } from "hono-rate-limiter" import { createMiddleware } from "hono/factory" import { HTTPException } from "hono/http-exception" import { languageDetector } from "hono/language" import { type TimeExpression, ms } from "qte" -import type { Session } from "#shared/auth.ts" -import type { AppContext } from "#shared/types.ts" +import type { AppContext, AuthenticatedAppContext } from "#shared/types.ts" import { baseLocale, locales } from "#shared/internationalization/runtime.js" export const withLanguageDetection = languageDetector({ @@ -16,9 +14,7 @@ export const withLanguageDetection = languageDetector({ supportedLanguages: [...locales], }) -export const requireSession = createMiddleware< - DeepMerge ->(async (c, next) => { +export const requireSession = createMiddleware(async (c, next) => { const session = await c.var.auth.api.getSession({ headers: c.req.raw.headers, }) diff --git a/apps/api/src/shared/types.ts b/apps/api/src/shared/types.ts index 6f97f928..7d8588f6 100644 --- a/apps/api/src/shared/types.ts +++ b/apps/api/src/shared/types.ts @@ -1,6 +1,7 @@ import type { Database } from "@init/db/client" import type { KeyValue } from "@init/kv/client" -import type { Auth } from "#shared/auth.ts" +import type { DeepMerge } from "@init/utils/type" +import type { Auth, Session } from "#shared/auth.ts" import type { Locale } from "#shared/internationalization/runtime.js" import type { logger } from "#shared/logger.ts" @@ -15,3 +16,5 @@ export type AppContext = { logger: AppLogger } } + +export type AuthenticatedAppContext = DeepMerge diff --git a/apps/api/src/shared/utils.ts b/apps/api/src/shared/utils.ts index 85334605..728c22d0 100644 --- a/apps/api/src/shared/utils.ts +++ b/apps/api/src/shared/utils.ts @@ -1,3 +1,4 @@ +import { getContext } from "hono/context-storage" import { createFactory } from "hono/factory" import type { AppContext } from "#shared/types.ts" @@ -5,3 +6,7 @@ import type { AppContext } from "#shared/types.ts" * A utility function to create Hono apps and middlewares with the correct context type. */ export const factory = createFactory() + +export function context() { + return getContext() +} diff --git a/apps/web/src/features/landing/components/technology-marquee.astro b/apps/web/src/features/landing/components/technology-marquee.astro index d1cbb49f..693254a5 100644 --- a/apps/web/src/features/landing/components/technology-marquee.astro +++ b/apps/web/src/features/landing/components/technology-marquee.astro @@ -10,12 +10,12 @@ import { m } from "#shared/internationalization/messages.js" >

{m.web_landing_technologies_heading()}

    diff --git a/apps/web/src/shared/components/layout.astro b/apps/web/src/shared/components/layout.astro index 874a496b..fc5988bf 100644 --- a/apps/web/src/shared/components/layout.astro +++ b/apps/web/src/shared/components/layout.astro @@ -1,59 +1,71 @@ --- -import { getLocale } from "#shared/internationalization/runtime.js" -import { m } from "#shared/internationalization/messages.js" -import { SITE_NAME, SITE_URL } from "#shared/constants.ts" -import "@init/ui/globals.css" +import { getLocale } from "#shared/internationalization/runtime.js"; +import { m } from "#shared/internationalization/messages.js"; +import { SITE_NAME, SITE_URL } from "#shared/constants.ts"; +import "@init/ui/globals.css"; type Props = { - description?: string - title?: string -} + description?: string; + title?: string; +}; -const locale = getLocale() -const tagline = `${m.web_landing_hero_headline_first()} ${m.web_landing_hero_headline_second()}` +const locale = getLocale(); +const tagline = `${m.web_landing_hero_description()}`; const { - description = m.web_landing_hero_description(), - title = `${SITE_NAME} — ${tagline}`, -} = Astro.props -const canonicalUrl = new URL(Astro.url.pathname, SITE_URL) -const socialImageUrl = new URL("/og-image.png", SITE_URL) + description = m.web_landing_hero_description(), + title = `${SITE_NAME} | ${tagline}`, +} = Astro.props; +const canonicalUrl = new URL(Astro.url.pathname, SITE_URL); +const socialImageUrl = new URL("/og-image.png", SITE_URL); --- - - - - - - - - - - - - - - - - - - - - - - - - {title} - - - - + + + + + + + + + + + + + + + + + + + + + + + + {title} + + + +