betterstart-cli 0.0.63 → 0.0.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ export const r2Integration: BetterstartIntegrationDefinition = {
7
7
  kind: 'storage',
8
8
  version: '1.0.0',
9
9
  dependencies: [],
10
- conflicts: [],
10
+ conflicts: ['vercel-blob', 'railway-bucket'],
11
11
  files: [
12
12
  {
13
13
  outputPath: 'lib/actions/r2/provider.ts',
@@ -0,0 +1,93 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { AdminStorageProvider, AdminStorageUploadInput } from '@admin/actions/storage/types'
3
+ import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
4
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
5
+
6
+ let railwayBucketClient: S3Client | null = null
7
+
8
+ function getConfig() {
9
+ return {
10
+ endpoint: process.env.BETTERSTART_RAILWAY_BUCKET_ENDPOINT?.trim() ?? '',
11
+ accessKeyId: process.env.BETTERSTART_RAILWAY_BUCKET_ACCESS_KEY_ID?.trim() ?? '',
12
+ secretAccessKey: process.env.BETTERSTART_RAILWAY_BUCKET_SECRET_ACCESS_KEY?.trim() ?? '',
13
+ bucketName: process.env.BETTERSTART_RAILWAY_BUCKET_NAME?.trim() ?? '',
14
+ region: process.env.BETTERSTART_RAILWAY_BUCKET_REGION?.trim() ?? '',
15
+ urlStyle: process.env.BETTERSTART_RAILWAY_BUCKET_URL_STYLE?.trim() ?? ''
16
+ }
17
+ }
18
+
19
+ function sanitizeFilename(filename: string): string {
20
+ return filename.replace(/[^a-zA-Z0-9.-]/g, '_')
21
+ }
22
+
23
+ function buildKey(filename: string, prefix = 'uploads'): string {
24
+ return `${prefix}/${Date.now()}-${randomUUID()}-${sanitizeFilename(filename)}`
25
+ }
26
+
27
+ function stableReadUrl(key: string): string {
28
+ return `/api/admin/storage/${Buffer.from(key, 'utf-8').toString('base64url')}`
29
+ }
30
+
31
+ function getClient(): S3Client {
32
+ if (railwayBucketClient) return railwayBucketClient
33
+
34
+ const config = getConfig()
35
+ railwayBucketClient = new S3Client({
36
+ region: config.region,
37
+ endpoint: config.endpoint,
38
+ forcePathStyle: config.urlStyle.toLowerCase().includes('path'),
39
+ credentials: {
40
+ accessKeyId: config.accessKeyId,
41
+ secretAccessKey: config.secretAccessKey
42
+ }
43
+ })
44
+ return railwayBucketClient
45
+ }
46
+
47
+ export const railwayBucketStorageProvider = {
48
+ id: 'railway-bucket',
49
+ isConfigured() {
50
+ const config = getConfig()
51
+ return Boolean(
52
+ config.endpoint &&
53
+ config.accessKeyId &&
54
+ config.secretAccessKey &&
55
+ config.bucketName &&
56
+ config.region &&
57
+ config.urlStyle
58
+ )
59
+ },
60
+ async saveFile(input: AdminStorageUploadInput) {
61
+ const config = getConfig()
62
+ if (!this.isConfigured()) {
63
+ throw new Error('Railway Bucket is not fully configured.')
64
+ }
65
+
66
+ const key = buildKey(input.filename, input.prefix)
67
+ await getClient().send(
68
+ new PutObjectCommand({
69
+ Bucket: config.bucketName,
70
+ Key: key,
71
+ Body: input.buffer,
72
+ ContentType: input.contentType,
73
+ ContentLength: input.contentLength
74
+ })
75
+ )
76
+
77
+ return { key, url: stableReadUrl(key) }
78
+ },
79
+ async createReadUrl(key: string) {
80
+ const config = getConfig()
81
+ if (!this.isConfigured()) {
82
+ throw new Error('Railway Bucket is not fully configured.')
83
+ }
84
+
85
+ return getSignedUrl(
86
+ getClient(),
87
+ new GetObjectCommand({ Bucket: config.bucketName, Key: key }),
88
+ { expiresIn: 300 }
89
+ )
90
+ }
91
+ } satisfies AdminStorageProvider & {
92
+ createReadUrl(key: string): Promise<string>
93
+ }
@@ -0,0 +1,40 @@
1
+ import type { BetterstartIntegrationDefinition } from '@integration-engine/types.js'
2
+
3
+ export const railwayBucketIntegration: BetterstartIntegrationDefinition = {
4
+ id: 'railway-bucket',
5
+ title: 'Railway Bucket',
6
+ description: 'Private S3-compatible media storage on Railway with signed reads',
7
+ kind: 'storage',
8
+ version: '1.0.0',
9
+ dependencies: [],
10
+ conflicts: ['r2', 'vercel-blob'],
11
+ files: [
12
+ {
13
+ outputPath: 'lib/actions/railway-bucket/provider.ts',
14
+ templatePath: 'actions/railway-bucket.ts'
15
+ }
16
+ ],
17
+ packageDependencies: ['@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner'],
18
+ devDependencies: [],
19
+ capabilities: {
20
+ storageProvider: {
21
+ provider: 'railway-bucket',
22
+ importPath: '@admin/actions/railway-bucket/provider',
23
+ exportName: 'railwayBucketStorageProvider'
24
+ }
25
+ },
26
+ envSections: [
27
+ {
28
+ header: 'Storage (Railway Bucket)',
29
+ vars: [
30
+ { key: 'BETTERSTART_RAILWAY_BUCKET_ENDPOINT', value: '' },
31
+ { key: 'BETTERSTART_RAILWAY_BUCKET_ACCESS_KEY_ID', value: '' },
32
+ { key: 'BETTERSTART_RAILWAY_BUCKET_SECRET_ACCESS_KEY', value: '' },
33
+ { key: 'BETTERSTART_RAILWAY_BUCKET_NAME', value: '' },
34
+ { key: 'BETTERSTART_RAILWAY_BUCKET_REGION', value: '' },
35
+ { key: 'BETTERSTART_RAILWAY_BUCKET_URL_STYLE', value: '' }
36
+ ]
37
+ }
38
+ ],
39
+ configure: 'railway-bucket'
40
+ }
@@ -7,7 +7,7 @@ export const vercelBlobIntegration: BetterstartIntegrationDefinition = {
7
7
  kind: 'storage',
8
8
  version: '1.0.0',
9
9
  dependencies: [],
10
- conflicts: [],
10
+ conflicts: ['r2', 'railway-bucket'],
11
11
  files: [
12
12
  {
13
13
  outputPath: 'lib/actions/vercel-blob/provider.ts',
@@ -0,0 +1,43 @@
1
+ import { getStorageProvider } from '@admin/actions/storage/provider'
2
+ import { type NextRequest, NextResponse } from 'next/server'
3
+
4
+ type SignedReadStorageProvider = ReturnType<typeof getStorageProvider> & {
5
+ createReadUrl?: (storageKey: string) => Promise<string>
6
+ }
7
+
8
+ function decodeStorageKey(encodedKey: string): string | undefined {
9
+ try {
10
+ const key = Buffer.from(encodedKey, 'base64url').toString('utf-8')
11
+ const canonicalKey = Buffer.from(key, 'utf-8').toString('base64url')
12
+ return key && canonicalKey === encodedKey ? key : undefined
13
+ } catch {
14
+ return undefined
15
+ }
16
+ }
17
+
18
+ function storageError(error: string, status: number) {
19
+ return NextResponse.json({ error }, { status })
20
+ }
21
+
22
+ export async function GET(request: NextRequest) {
23
+ const encodedKey = request.nextUrl.pathname.split('/').filter(Boolean).at(-1)
24
+ const key = encodedKey ? decodeStorageKey(encodedKey) : undefined
25
+ const provider: SignedReadStorageProvider = getStorageProvider()
26
+
27
+ if (!key || !provider.createReadUrl) {
28
+ return storageError('Media object not found', 404)
29
+ }
30
+
31
+ try {
32
+ const signedUrl = await provider.createReadUrl(key)
33
+ return new NextResponse(null, {
34
+ status: 307,
35
+ headers: {
36
+ Location: signedUrl,
37
+ 'Cache-Control': 'no-store'
38
+ }
39
+ })
40
+ } catch {
41
+ return storageError('Could not create a media read URL', 502)
42
+ }
43
+ }
@@ -14,5 +14,6 @@ export interface AdminStorageUploadResult {
14
14
  export interface AdminStorageProvider {
15
15
  id: string
16
16
  isConfigured: () => boolean
17
- saveFile: (input: AdminStorageUploadInput) => Promise<AdminStorageUploadResult>
17
+ saveFile(input: AdminStorageUploadInput): Promise<AdminStorageUploadResult>
18
+ createReadUrl?: (key: string) => Promise<string>
18
19
  }