@r8s/eurooffice 0.2.0

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.
@@ -0,0 +1,427 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, jsx } from '@r8s/core'
3
+ import { Namespace, OperatorContext, SecretContext, RoutingContext } from '@r8s/core/defaults'
4
+ import { runGuardrails, noPlaintextSecrets, validateResource } from '@r8s/core'
5
+ import { operators } from '@r8s/crds'
6
+ import type { r8sElement } from '@r8s/core'
7
+
8
+ // EuroOffice recipe tests:
9
+ // 1. Operator declarations (deduped via OperatorContext)
10
+ // 2. Rendering: defaults, all props, gateway/ingress adaptation, TCP probes
11
+ // 3. Namespace inheritance from the Platform context
12
+ // 4. Security: no plaintext credentials in rendered output
13
+ import { EuroOffice } from '../src/index'
14
+
15
+ const objectStorage = {
16
+ endpoint: 'https://s3.test',
17
+ bucket: 'docs-blobs',
18
+ credentialsSecret: 'docs-blobs-credentials',
19
+ }
20
+
21
+ const openbao = { backend: 'openbao', mount: 'kv', path: 'test' }
22
+
23
+ /** Render EuroOffice inside a Platform-like secrets backend (OpenBao). */
24
+ function renderEuroOffice(props: Record<string, unknown>): ReturnType<typeof render> {
25
+ return render(
26
+ jsx(SecretContext.Provider, {
27
+ value: { backend: 'openbao', mount: 'kv', path: 'test' },
28
+ children: jsx(EuroOffice, { objectStorage, ...props } as never),
29
+ })
30
+ )
31
+ }
32
+
33
+ /** Render EuroOffice wrapped only in an OperatorContext (no secrets backend). */
34
+ function renderEuroOfficeWithContext(
35
+ operators_: any[],
36
+ props: Record<string, unknown>
37
+ ): r8sElement {
38
+ return jsx(OperatorContext.Provider, {
39
+ value: operators_,
40
+ children: jsx(EuroOffice, {
41
+ objectStorage,
42
+ secretsName: 'existing-secrets',
43
+ ...props,
44
+ } as never),
45
+ })
46
+ }
47
+
48
+ /** Render EuroOffice inside a Platform-like Namespace context (no explicit namespace prop). */
49
+ function renderEuroOfficeInNamespace(
50
+ namespaceValue: string,
51
+ props: Record<string, unknown>
52
+ ): ReturnType<typeof render> {
53
+ return render(
54
+ jsx(Namespace.Provider, {
55
+ value: namespaceValue,
56
+ children: jsx(SecretContext.Provider, {
57
+ value: openbao as never,
58
+ children: jsx(EuroOffice, { objectStorage, ...props } as never),
59
+ }),
60
+ })
61
+ )
62
+ }
63
+
64
+ describe('operator declarations', () => {
65
+ it('declares the cnpg operator via the Database recipe', () => {
66
+ const result = renderEuroOffice({ host: 'docs.example.com' })
67
+ expect(result.operators.some((op) => op.name === 'cnpg')).toBe(true)
68
+ })
69
+
70
+ it('adds no app-level operators beyond cnpg (Endpoint owns routing operators)', () => {
71
+ const result = renderEuroOffice({ host: 'docs.example.com', conversions: true })
72
+ const names = result.operators.map((op) => op.name)
73
+ expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
74
+ expect(names).not.toContain('redis-operator')
75
+ })
76
+
77
+ it('deduplicates operators provided via context', () => {
78
+ const result = render(
79
+ renderEuroOfficeWithContext([operators['cnpg']()], { host: 'docs.example.com' })
80
+ )
81
+ const names = result.operators.map((op) => op.name)
82
+ expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
83
+ })
84
+
85
+ it('allows version overrides through context operators', () => {
86
+ const result = render(renderEuroOfficeWithContext([operators['cnpg']('1.24.0')], {}))
87
+ const cnpg = result.operators.filter((op) => op.name === 'cnpg')
88
+ expect(cnpg).toHaveLength(1)
89
+ expect(cnpg[0].version).toBe('1.24.0')
90
+ })
91
+ })
92
+
93
+ describe('rendering defaults', () => {
94
+ it('renders app deployment, service, database and endpoint', () => {
95
+ const result = renderEuroOffice({ host: 'docs.example.com' })
96
+ const kinds = result.resources.map((r) => r.kind)
97
+ expect(kinds).toContain('Deployment')
98
+ expect(kinds).toContain('Service')
99
+ expect(kinds).toContain('Ingress')
100
+ expect(kinds).toContain('Cluster')
101
+ })
102
+
103
+ it('adds LibreOffice conversion workers when conversions is set', () => {
104
+ const result = renderEuroOffice({
105
+ host: 'docs.example.com',
106
+ conversions: true,
107
+ conversionWorkers: 2,
108
+ })
109
+ const deployments = result.resources.filter((r) => r.kind === 'Deployment')
110
+ const soffice = deployments.find((d: any) => d.metadata.name === 'eurooffice-soffice') as any
111
+ expect(soffice).toBeDefined()
112
+ expect(soffice.spec.replicas).toBe(2)
113
+ expect(soffice.spec.template.spec.containers[0].command).toContain(
114
+ '--accept=socket,host=0.0.0.0,port=2002;urp;'
115
+ )
116
+ const services = result.resources.filter((r) => r.kind === 'Service')
117
+ expect(services.some((s: any) => s.metadata.name === 'eurooffice-soffice')).toBe(true)
118
+ })
119
+
120
+ it('probes the soffice workers on the UNO TCP socket (not HTTP)', () => {
121
+ const result = renderEuroOffice({ host: 'docs.example.com', conversions: true })
122
+ const soffice = result.resources.find(
123
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice-soffice'
124
+ ) as any
125
+ const container = soffice.spec.template.spec.containers[0]
126
+ expect(container.livenessProbe.tcpSocket).toEqual({ port: 2002 })
127
+ expect(container.readinessProbe.tcpSocket).toEqual({ port: 2002 })
128
+ expect(container.readinessProbe.initialDelaySeconds).toBe(20)
129
+ expect(container.livenessProbe.httpGet).toBeUndefined()
130
+ expect(container.readinessProbe.httpGet).toBeUndefined()
131
+ })
132
+
133
+ it('does not render conversion workers by default', () => {
134
+ const result = renderEuroOffice({ host: 'docs.example.com' })
135
+ const deployments = result.resources
136
+ .filter((r) => r.kind === 'Deployment')
137
+ .map((d: any) => d.metadata.name)
138
+ expect(deployments).not.toContain('eurooffice-soffice')
139
+ })
140
+
141
+ it('defaults app replicas to 1 when websockets are enabled (no session affinity)', () => {
142
+ const result = renderEuroOffice({ host: 'docs.example.com' })
143
+ const app = result.resources.find(
144
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
145
+ ) as any
146
+ expect(app.spec.replicas).toBe(1)
147
+ })
148
+
149
+ it('defaults app replicas to 2 when websockets are disabled', () => {
150
+ const result = renderEuroOffice({ host: 'docs.example.com', websockets: false })
151
+ const app = result.resources.find(
152
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
153
+ ) as any
154
+ expect(app.spec.replicas).toBe(2)
155
+ })
156
+
157
+ it('renders explicit replicas with websockets enabled (caller opts into sticky sessions)', () => {
158
+ const result = renderEuroOffice({
159
+ host: 'docs.example.com',
160
+ websockets: true,
161
+ replicas: 3,
162
+ })
163
+ const app = result.resources.find(
164
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
165
+ ) as any
166
+ expect(app.spec.replicas).toBe(3)
167
+ })
168
+
169
+ it('enables websockets by default and allows disabling them', () => {
170
+ const on = renderEuroOffice({ host: 'docs.example.com' })
171
+ const appOn = on.resources.find(
172
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
173
+ ) as any
174
+ const envOn = appOn.spec.template.spec.containers[0].env
175
+ expect(envOn.find((e: any) => e.name === 'WEBSOCKETS_ENABLED').value).toBe('true')
176
+
177
+ const off = renderEuroOffice({ host: 'docs.example.com', websockets: false })
178
+ const appOff = off.resources.find(
179
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
180
+ ) as any
181
+ const envOff = appOff.spec.template.spec.containers[0].env
182
+ expect(envOff.find((e: any) => e.name === 'WEBSOCKETS_ENABLED').value).toBe('false')
183
+ })
184
+
185
+ it('renders gateway resources when platform uses gateway routing', () => {
186
+ const result = render(
187
+ jsx(RoutingContext.Provider, {
188
+ value: { mode: 'gateway', gatewayClassName: 'eg' },
189
+ children: jsx(SecretContext.Provider, {
190
+ value: openbao as never,
191
+ children: jsx(EuroOffice, { objectStorage, host: 'docs.example.com' } as never),
192
+ }),
193
+ })
194
+ )
195
+ const kinds = result.resources.map((r) => r.kind)
196
+ expect(kinds).toContain('HTTPRoute')
197
+ })
198
+
199
+ it('renders a valid Ingress when platform uses ingress routing', () => {
200
+ const result = render(
201
+ jsx(RoutingContext.Provider, {
202
+ value: { mode: 'ingress' },
203
+ children: jsx(SecretContext.Provider, {
204
+ value: openbao as never,
205
+ children: jsx(EuroOffice, { objectStorage, host: 'docs.example.com' } as never),
206
+ }),
207
+ })
208
+ )
209
+ const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
210
+ expect(ingress).toBeDefined()
211
+ expect(ingress.spec.rules[0].host).toBe('docs.example.com')
212
+ })
213
+
214
+ it('passes resource validation', () => {
215
+ const result = renderEuroOffice({ host: 'docs.example.com', conversions: true })
216
+ for (const resource of result.resources) {
217
+ expect(validateResource(resource)).toEqual([])
218
+ }
219
+ })
220
+ })
221
+
222
+ describe('namespace inheritance', () => {
223
+ it('inherits namespace from the Platform context when namespace prop is not set', () => {
224
+ const result = renderEuroOfficeInNamespace('docs', { host: 'docs.example.com' })
225
+ for (const kind of ['Deployment', 'Service', 'Cluster', 'Ingress']) {
226
+ const resource = result.resources.find((r: any) => r.kind === kind)
227
+ expect(resource).toBeDefined()
228
+ expect(resource.metadata.namespace).toBe('docs')
229
+ }
230
+ })
231
+
232
+ it('explicit namespace prop wins over the Platform context', () => {
233
+ const result = renderEuroOfficeInNamespace('docs', {
234
+ host: 'docs.example.com',
235
+ namespace: 'collab',
236
+ })
237
+ const app = result.resources.find(
238
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
239
+ ) as any
240
+ expect(app.metadata.namespace).toBe('collab')
241
+ })
242
+
243
+ it('falls back to default when no Platform namespace is present', () => {
244
+ const result = renderEuroOffice({ host: 'docs.example.com' })
245
+ const app = result.resources.find(
246
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
247
+ ) as any
248
+ expect(app.metadata.namespace).toBe('default')
249
+ })
250
+ })
251
+
252
+ describe('rendering with all props', () => {
253
+ it('accepts the full prop surface', () => {
254
+ const result = renderEuroOffice({
255
+ name: 'docs',
256
+ namespace: 'docs',
257
+ version: '1.2.0',
258
+ host: 'docs.example.com',
259
+ replicas: 3,
260
+ websockets: false,
261
+ smtp: { host: 'smtp.example.com', port: 465, from: 'no-reply@docs.example.com' },
262
+ conversions: true,
263
+ conversionWorkers: 4,
264
+ objectStorage: {
265
+ endpoint: 'https://s3.internal.example.com',
266
+ bucket: 'docs-blobs',
267
+ credentialsSecret: 'docs-blobs-credentials',
268
+ region: 'eu-north-1',
269
+ },
270
+ resources: {
271
+ requests: { memory: '1Gi', cpu: '500m' },
272
+ limits: { memory: '4Gi', cpu: '2000m' },
273
+ },
274
+ tls: { secretName: 'docs-tls', clusterIssuer: 'letsencrypt-prod' },
275
+ })
276
+ expect(result.resources.length).toBeGreaterThan(0)
277
+ const app = result.resources.find(
278
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'docs'
279
+ ) as any
280
+ expect(app.spec.template.spec.containers[0].image).toBe('ghcr.io/berget-ai/eurooffice:1.2.0')
281
+ expect(app.spec.replicas).toBe(3)
282
+ expect(app.spec.template.spec.containers[0].resources.limits.memory).toBe('4Gi')
283
+ const env = app.spec.template.spec.containers[0].env
284
+ expect(env.find((e: any) => e.name === 'WEBSOCKETS_ENABLED').value).toBe('false')
285
+ expect(env.find((e: any) => e.name === 'SMTP_HOST').value).toBe('smtp.example.com')
286
+ expect(env.find((e: any) => e.name === 'SMTP_PORT').value).toBe('465')
287
+ expect(env.find((e: any) => e.name === 'SMTP_FROM').value).toBe('no-reply@docs.example.com')
288
+ expect(env.find((e: any) => e.name === 'AWS_REGION').value).toBe('eu-north-1')
289
+ expect(env.find((e: any) => e.name === 'SOFFICE_HOST').value).toBe('docs-soffice')
290
+
291
+ const soffice = result.resources.find(
292
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'docs-soffice'
293
+ ) as any
294
+ expect(soffice.spec.template.spec.containers[0].image).toBe(
295
+ 'ghcr.io/berget-ai/eurooffice:1.2.0'
296
+ )
297
+ expect(soffice.spec.replicas).toBe(4)
298
+ })
299
+ })
300
+
301
+ describe('secrets handling', () => {
302
+ it('accepts an explicit secretsName without a backend', () => {
303
+ expect(() =>
304
+ render(
305
+ jsx(EuroOffice, {
306
+ host: 'docs.example.com',
307
+ objectStorage,
308
+ secretsName: 'existing-secrets',
309
+ } as never)
310
+ )
311
+ ).not.toThrow()
312
+
313
+ const result = render(
314
+ jsx(EuroOffice, {
315
+ host: 'docs.example.com',
316
+ objectStorage,
317
+ secretsName: 'existing-secrets',
318
+ } as never)
319
+ )
320
+ const kinds = result.resources.map((r) => r.kind)
321
+ expect(kinds).not.toContain('OpenBaoStaticSecret')
322
+ const app = result.resources.find(
323
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
324
+ ) as any
325
+ const env = app.spec.template.spec.containers[0].env
326
+ const appSecret = env.find((e: any) => e.name === 'APP_SECRET')
327
+ expect(appSecret.valueFrom.secretKeyRef.name).toBe('existing-secrets')
328
+ expect(appSecret.valueFrom.secretKeyRef.key).toBe('secretKey')
329
+ })
330
+
331
+ it('provisions the app secrets bundle through a secrets backend', () => {
332
+ const result = renderEuroOffice({ host: 'docs.example.com' })
333
+ const secretCr = result.resources.find((r) => r.kind === 'OpenBaoStaticSecret') as any
334
+ expect(secretCr).toBeDefined()
335
+ expect(secretCr.metadata.name).toBe('eurooffice-secrets')
336
+ expect(secretCr.spec.destination.name).toBe('eurooffice-secrets')
337
+ expect(secretCr.spec.path).toBe('test/eurooffice/secrets')
338
+ })
339
+
340
+ it('provisions the app secrets bundle through Vault', () => {
341
+ const result = render(
342
+ jsx(SecretContext.Provider, {
343
+ value: { backend: 'vault', mount: 'kv', path: 'apps' },
344
+ children: jsx(EuroOffice, { objectStorage, host: 'docs.example.com' } as never),
345
+ })
346
+ )
347
+ const kinds = result.resources.map((r) => r.kind)
348
+ expect(kinds).toContain('VaultStaticSecret')
349
+ const secretCr = result.resources.find((r) => r.kind === 'VaultStaticSecret') as any
350
+ expect(secretCr.spec.path).toBe('apps/eurooffice/secrets')
351
+ })
352
+
353
+ it('wires credentials via secretKeyRef (never plaintext env)', () => {
354
+ const result = renderEuroOffice({
355
+ host: 'docs.example.com',
356
+ smtp: { host: 'smtp.example.com' },
357
+ })
358
+ const app = result.resources.find(
359
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
360
+ ) as any
361
+ const env = app.spec.template.spec.containers[0].env
362
+ const smtpPassword = env.find((e: any) => e.name === 'SMTP_PASSWORD')
363
+ const appSecret = env.find((e: any) => e.name === 'APP_SECRET')
364
+ const accessKey = env.find((e: any) => e.name === 'AWS_ACCESS_KEY_ID')
365
+ const secretAccessKey = env.find((e: any) => e.name === 'AWS_SECRET_ACCESS_KEY')
366
+ expect(smtpPassword.valueFrom.secretKeyRef.name).toBe('eurooffice-secrets')
367
+ expect(smtpPassword.valueFrom.secretKeyRef.key).toBe('smtpPassword')
368
+ expect(appSecret.valueFrom.secretKeyRef.name).toBe('eurooffice-secrets')
369
+ expect(appSecret.valueFrom.secretKeyRef.key).toBe('secretKey')
370
+ expect(accessKey.valueFrom.secretKeyRef.name).toBe('docs-blobs-credentials')
371
+ expect(accessKey.valueFrom.secretKeyRef.key).toBe('accessKey')
372
+ expect(secretAccessKey.valueFrom.secretKeyRef.name).toBe('docs-blobs-credentials')
373
+ expect(secretAccessKey.valueFrom.secretKeyRef.key).toBe('secretKey')
374
+ expect(smtpPassword.value).toBeUndefined()
375
+ expect(appSecret.value).toBeUndefined()
376
+ expect(accessKey.value).toBeUndefined()
377
+ expect(secretAccessKey.value).toBeUndefined()
378
+ })
379
+
380
+ it('auto-wires DATABASE_URL from the DatabaseContext (no manual connection string)', () => {
381
+ const result = renderEuroOffice({ host: 'docs.example.com' })
382
+ const app = result.resources.find(
383
+ (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
384
+ ) as any
385
+ const env = app.spec.template.spec.containers[0].env
386
+ const databaseUrl = env.find((e: any) => e.name === 'DATABASE_URL')
387
+ expect(databaseUrl.value).toBe(
388
+ 'postgresql://$(PGUSER):$(PGPASSWORD)@$(PGHOST):$(PGPORT)/$(PGDATABASE)'
389
+ )
390
+ const dbPassword = env.find((e: any) => e.name === 'PGPASSWORD')
391
+ expect(dbPassword.valueFrom.secretKeyRef.name).toBe('eurooffice-db-credentials')
392
+ expect(dbPassword.value).toBeUndefined()
393
+ })
394
+
395
+ it('renders no plaintext credentials anywhere', () => {
396
+ const result = renderEuroOffice({
397
+ host: 'docs.example.com',
398
+ smtp: { host: 'smtp.example.com', port: 587, from: 'no-reply@docs.example.com' },
399
+ conversions: true,
400
+ conversionWorkers: 2,
401
+ })
402
+ const { passed, errors } = runGuardrails(result.resources as any[], [noPlaintextSecrets])
403
+ if (!passed) {
404
+ console.error('Plaintext credential violations:', errors)
405
+ }
406
+ expect(passed).toBe(true)
407
+ })
408
+ })
409
+
410
+ describe('validation errors', () => {
411
+ it('throws when no secrets backend and no secrets name', () => {
412
+ expect(() =>
413
+ render(jsx(EuroOffice, { host: 'docs.example.com', objectStorage } as never))
414
+ ).toThrow(/application secrets/)
415
+ })
416
+
417
+ it('throws for unknown secrets backends', () => {
418
+ expect(() =>
419
+ render(
420
+ jsx(SecretContext.Provider, {
421
+ value: { backend: 'unknown' as never },
422
+ children: jsx(EuroOffice, { objectStorage, host: 'docs.example.com' } as never),
423
+ })
424
+ )
425
+ ).toThrow(/application secrets/)
426
+ })
427
+ })
@@ -0,0 +1,17 @@
1
+ import { Platform } from '@r8s/recipes'
2
+ import { EuroOffice } from '@r8s/eurooffice'
3
+
4
+ export default (
5
+ <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
6
+ <EuroOffice
7
+ name="docs"
8
+ host="docs.example.com"
9
+ objectStorage={{
10
+ endpoint: 'https://s3.internal.example.com',
11
+ bucket: 'docs-blobs',
12
+ credentialsSecret: 'docs-blobs-credentials',
13
+ }}
14
+ smtp={{ host: 'smtp.example.com', port: 587, from: 'no-reply@${env:MAIL_DOMAIN}' }}
15
+ />
16
+ </Platform>
17
+ )
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@r8s/eurooffice",
3
+ "version": "0.2.0",
4
+ "description": "EuroOffice collaborative document suite — Postgres persistence, S3 blob storage, LibreOffice conversions, SMTP delivery and websocket collaboration",
5
+ "license": "MIT",
6
+ "author": "Berget AI AB",
7
+ "homepage": "https://r8s.berget.ai",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/berget-ai/r8s.git",
11
+ "directory": "packages/eurooffice"
12
+ },
13
+ "bugs": "https://github.com/berget-ai/r8s/issues",
14
+ "keywords": [
15
+ "eurooffice",
16
+ "libreoffice",
17
+ "documents",
18
+ "conversion"
19
+ ],
20
+ "r8s": {
21
+ "category": "Productivity & Documents"
22
+ },
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "scripts": {
26
+ "build": "tsc --build",
27
+ "test": "vitest run",
28
+ "clean": "rm -rf dist tsconfig.tsbuildinfo"
29
+ },
30
+ "category": "Collaboration & Productivity",
31
+ "dependencies": {
32
+ "@r8s/core": "^0.2.0",
33
+ "@r8s/crds": "^0.2.0",
34
+ "@r8s/k8s-types": "^0.2.0",
35
+ "@r8s/recipes": "^0.2.0"
36
+ },
37
+ "devDependencies": {
38
+ "typescript": "^5.3.0",
39
+ "vitest": "^1.0.0"
40
+ }
41
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,324 @@
1
+ import { jsx, Fragment, useContext } from '@r8s/core'
2
+ import { Namespace, SecretContext } from '@r8s/core/defaults'
3
+ import { Database, WebService, Endpoint } from '@r8s/recipes'
4
+ import type { SecretRef } from '@r8s/recipes'
5
+
6
+ export interface EuroOfficeProps {
7
+ /** Resource name (defaults to 'eurooffice') */
8
+ name?: string
9
+ /** Kubernetes namespace (defaults to 'default') */
10
+ namespace?: string
11
+ /** Container image tag (defaults to 'latest' — pin a version in production) */
12
+ version?: string
13
+ /** Public hostname for the document suite (required) */
14
+ host: string
15
+ /**
16
+ * Number of app replicas.
17
+ *
18
+ * With `websockets` enabled (the default) this defaults to **1**:
19
+ * websocket sessions are pinned to the pod that accepted the
20
+ * connection and the workload has no session affinity, so >1 replicas
21
+ * means collaborators on different pods stop seeing each other live.
22
+ * An explicitly set `replicas` is rendered as asked — pair it with a
23
+ * sticky-session (source-IP) ingress strategy for live co-editing.
24
+ * With `websockets: false` the default is 2 (the app is stateless —
25
+ * scale freely).
26
+ */
27
+ replicas?: number
28
+ /**
29
+ * Collaborative editing over websockets (default: true). Disable only
30
+ * for single-user or read-only deployments — document presence,
31
+ * cursor sharing and live co-editing all rely on websockets.
32
+ */
33
+ websockets?: boolean
34
+ /**
35
+ * S3-compatible object storage for document blobs and attachments
36
+ * (RustFS in the platform). Required — reference a bucket whose
37
+ * credentials live in a Secret provisioned by the secrets backend
38
+ * (keys: accessKey, secretKey) — never plaintext.
39
+ */
40
+ objectStorage: {
41
+ /** S3 endpoint URL, e.g. https://s3.internal.example.com */
42
+ endpoint: string
43
+ /** Bucket name for document blobs */
44
+ bucket: string
45
+ /** Name of the Secret holding accessKey / secretKey */
46
+ credentialsSecret: string
47
+ /** Region string for the S3 client (defaults to 'us-east-1') */
48
+ region?: string
49
+ }
50
+ /**
51
+ * Outgoing SMTP for invitations and notifications. The SMTP password is
52
+ * delivered via secretKeyRef from the `${name}-secrets` bundle
53
+ * (key: smtpPassword) — never plaintext.
54
+ */
55
+ smtp?: {
56
+ /** SMTP server hostname, e.g. smtp.example.com */
57
+ host: string
58
+ /** SMTP port (defaults to 587) */
59
+ port?: number
60
+ /** From address for outgoing mail, e.g. no-reply@example.com */
61
+ from?: string
62
+ }
63
+ /**
64
+ * LibreOffice headless document-conversion workers (same app image,
65
+ * which must include soffice). Adds a `${name}-soffice` Deployment +
66
+ * Service; the app reaches it at SOFFICE_HOST:SOFFICE_PORT. Workers
67
+ * serve the UNO socket (TCP), not HTTP — their probes probe the socket.
68
+ */
69
+ conversions?: boolean
70
+ /** LibreOffice conversion worker replicas (defaults to 1, only used with conversions) */
71
+ conversionWorkers?: number
72
+ /**
73
+ * Name of an existing Secret holding `secretKey` and `smtpPassword`.
74
+ * Required unless a secrets backend (openbao/vault) is configured on
75
+ * the surrounding Platform — the backend then provisions them.
76
+ * Plaintext values are not supported.
77
+ */
78
+ secretsName?: string
79
+ /** Requested app resources */
80
+ resources?: {
81
+ requests?: { cpu?: string; memory?: string }
82
+ limits?: { cpu?: string; memory?: string }
83
+ }
84
+ /** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
85
+ tls?: {
86
+ secretName: string
87
+ clusterIssuer: string
88
+ }
89
+ }
90
+
91
+ /**
92
+ * EuroOffice — collaborative document suite.
93
+ *
94
+ * @title EuroOffice
95
+ * @category Productivity & Documents
96
+ *
97
+ * Composes:
98
+ * - CNPG Postgres cluster (documents, revisions, users; DATABASE_URL is
99
+ * auto-wired from the DatabaseContext — no manual connection string)
100
+ * - EuroOffice Deployment + Service + Endpoint (websockets enabled by
101
+ * default for live co-editing)
102
+ * - Required S3/RustFS object storage for document blobs
103
+ * - Optional LibreOffice headless conversion workers (same image; probed
104
+ * on their UNO TCP socket)
105
+ * - Optional SMTP delivery; password via the `${name}-secrets` bundle
106
+ * - APP_SECRET (and SMTP_PASSWORD) provisioned through the Platform
107
+ * secrets backend (openbao / vault), or referenced from an existing
108
+ * Secret via `secretsName`
109
+ *
110
+ * The namespace is inherited from the surrounding `<Platform>` (via the
111
+ * Namespace context) unless set explicitly.
112
+ *
113
+ * Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
114
+ * the app secrets bundle is provisioned for you. Without a backend you
115
+ * must point `secretsName` at a pre-created Secret.
116
+ *
117
+ * @example
118
+ * import { Platform } from '@r8s/recipes'
119
+ * import { EuroOffice } from '@r8s/eurooffice'
120
+ *
121
+ * export default (
122
+ * <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
123
+ * <EuroOffice
124
+ * name="docs"
125
+ * host="docs.example.com"
126
+ * objectStorage={{
127
+ * endpoint: 'https://s3.internal.example.com',
128
+ * bucket: 'docs-blobs',
129
+ * credentialsSecret: 'docs-blobs-credentials',
130
+ * }}
131
+ * smtp={{ host: 'smtp.example.com', port: 587, from: 'no-reply@${env:MAIL_DOMAIN}' }}
132
+ * />
133
+ * </Platform>
134
+ * )
135
+ */
136
+ export function EuroOffice(props: EuroOfficeProps) {
137
+ const {
138
+ name = 'eurooffice',
139
+ namespace: namespaceProp,
140
+ version = 'latest',
141
+ host,
142
+ replicas: replicasProp,
143
+ websockets = true,
144
+ objectStorage,
145
+ smtp,
146
+ conversions = false,
147
+ conversionWorkers = 1,
148
+ secretsName,
149
+ resources = {
150
+ requests: { memory: '512Mi', cpu: '250m' },
151
+ limits: { memory: '2Gi', cpu: '1000m' },
152
+ },
153
+ tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' },
154
+ } = props
155
+
156
+ // Inherit namespace from <Platform> context if not explicitly set
157
+ const contextNamespace = useContext(Namespace)
158
+ const namespace =
159
+ namespaceProp ?? (contextNamespace !== 'default' ? contextNamespace : undefined) ?? 'default'
160
+
161
+ // Live co-editing runs over websockets pinned to a single pod (no session
162
+ // affinity) — default to 1 replica unless the caller explicitly scales.
163
+ const replicas = replicasProp ?? (websockets ? 1 : 2)
164
+
165
+ const secretProvider = useContext(SecretContext)
166
+ const resources_: ReturnType<typeof jsx>[] = []
167
+
168
+ const platformSecretsName = secretsName ?? `${name}-secrets`
169
+
170
+ // --- App secrets (APP_SECRET / SMTP_PASSWORD) -----------------------------
171
+ // The app secret signs sessions and collaborator tokens — never render
172
+ // it as plaintext. With a secrets backend the bundle is provisioned
173
+ // through the backend (keys: secretKey, smtpPassword); otherwise
174
+ // reference a pre-created Secret.
175
+ if (!secretsName) {
176
+ if (
177
+ !secretProvider ||
178
+ (secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')
179
+ ) {
180
+ throw new Error(
181
+ `EuroOffice "${name}" requires application secrets (APP_SECRET, SMTP_PASSWORD).\n` +
182
+ `\n` +
183
+ `The app secret signs sessions and collaborator tokens — it must ` +
184
+ `not be rendered as plaintext.\n` +
185
+ `\n` +
186
+ `Fix: configure a secrets backend on the Platform:\n` +
187
+ ` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>\n` +
188
+ ` <EuroOffice name="${name}" host="${host}" />\n` +
189
+ ` </Platform>\n` +
190
+ `\n` +
191
+ `Or reference a pre-created Secret (keys: secretKey, smtpPassword):\n` +
192
+ ` <EuroOffice name="${name}" host="${host}" secretsName="${name}-secrets" />`
193
+ )
194
+ }
195
+
196
+ const spec = {
197
+ ...(secretProvider.backend === 'vault'
198
+ ? { vaultAuthRef: secretProvider.authRef }
199
+ : { openbaoAuthRef: secretProvider.authRef }),
200
+ mount: secretProvider.mount,
201
+ type: 'kv-v2' as const,
202
+ path: `${secretProvider.path ?? name}/${name}/secrets`,
203
+ destination: { create: true, name: platformSecretsName },
204
+ }
205
+ resources_.push(
206
+ secretProvider.backend === 'vault'
207
+ ? jsx('VaultStaticSecret', {
208
+ apiVersion: 'secrets.hashicorp.com/v1beta1',
209
+ kind: 'VaultStaticSecret',
210
+ metadata: { name: `${name}-secrets`, namespace },
211
+ spec,
212
+ })
213
+ : jsx('OpenBaoStaticSecret', {
214
+ apiVersion: 'secrets.openbao.org/v1beta1',
215
+ kind: 'OpenBaoStaticSecret',
216
+ metadata: { name: `${name}-secrets`, namespace },
217
+ spec,
218
+ })
219
+ )
220
+ }
221
+
222
+ // --- Env wiring -------------------------------------------------------------
223
+ // Every credential is delivered via secretKeyRef — no plaintext in the
224
+ // manifest. DATABASE_URL is auto-wired by WebService from the
225
+ // DatabaseContext (PG* vars + $(VAR) template), so it is not declared here.
226
+ const env: Record<string, string> = {
227
+ PORT: '3000',
228
+ APP_URL: `https://${host}`,
229
+ WEBSOCKETS_ENABLED: websockets ? 'true' : 'false',
230
+ AWS_REGION: objectStorage.region ?? 'us-east-1',
231
+ AWS_S3_UPLOAD_BUCKET_URL: `${objectStorage.endpoint}/${objectStorage.bucket}`,
232
+ AWS_S3_UPLOAD_BUCKET_NAME: objectStorage.bucket,
233
+ AWS_S3_ENDPOINT: objectStorage.endpoint,
234
+ AWS_S3_FORCE_PATH_STYLE: 'true',
235
+ ...(smtp
236
+ ? {
237
+ SMTP_HOST: smtp.host,
238
+ SMTP_PORT: String(smtp.port ?? 587),
239
+ ...(smtp.from && { SMTP_FROM: smtp.from }),
240
+ }
241
+ : {}),
242
+ ...(conversions
243
+ ? {
244
+ SOFFICE_HOST: `${name}-soffice`,
245
+ SOFFICE_PORT: '2002',
246
+ }
247
+ : {}),
248
+ }
249
+
250
+ // Credentials delivered via secretKeyRef (runtime injection)
251
+ const secrets: Record<string, SecretRef | string> = {
252
+ APP_SECRET: { secret: platformSecretsName, key: 'secretKey' },
253
+ ...(smtp
254
+ ? { SMTP_PASSWORD: { secret: platformSecretsName, key: 'smtpPassword' as const } }
255
+ : {}),
256
+ AWS_ACCESS_KEY_ID: { secret: objectStorage.credentialsSecret, key: 'accessKey' },
257
+ AWS_SECRET_ACCESS_KEY: { secret: objectStorage.credentialsSecret, key: 'secretKey' },
258
+ }
259
+
260
+ // --- Database + app + conversions + endpoint --------------------------------
261
+ // Database wraps the app so credentials stay consistent with the r8s
262
+ // Database recipe (CNPG dedicated cluster provisions the secret) and
263
+ // WebService auto-wires DATABASE_URL from the DatabaseContext.
264
+ resources_.push(
265
+ jsx(Database, {
266
+ name,
267
+ namespace,
268
+ storage: '10Gi',
269
+ children: (
270
+ <WebService
271
+ name={name}
272
+ namespace={namespace}
273
+ image={`ghcr.io/berget-ai/eurooffice:${version}`}
274
+ port={3000}
275
+ replicas={replicas}
276
+ resources={resources}
277
+ env={env}
278
+ secrets={secrets}
279
+ />
280
+ ),
281
+ })
282
+ )
283
+
284
+ // LibreOffice headless conversion workers — same app image (must include
285
+ // soffice), listening for UNO socket connections. The workers know HTTP
286
+ // health endpoints no better than we do, so probe the UNO TCP socket
287
+ // directly (httpGet /health would crash-loop them).
288
+ if (conversions) {
289
+ resources_.push(
290
+ <WebService
291
+ name={`${name}-soffice`}
292
+ namespace={namespace}
293
+ image={`ghcr.io/berget-ai/eurooffice:${version}`}
294
+ port={2002}
295
+ replicas={conversionWorkers}
296
+ command={[
297
+ 'soffice',
298
+ '--headless',
299
+ '--norestore',
300
+ '--accept=socket,host=0.0.0.0,port=2002;urp;',
301
+ ]}
302
+ env={{ SOFFICE_MODE: 'worker' }}
303
+ probes={{
304
+ liveness: { tcp: true, port: 2002 },
305
+ readiness: { tcp: true, port: 2002, initialDelaySeconds: 20 },
306
+ }}
307
+ />
308
+ )
309
+ }
310
+
311
+ // --- Endpoint — collaborative editing shares the app host -------------------
312
+ resources_.push(
313
+ <Endpoint
314
+ name={`${name}-endpoint`}
315
+ namespace={namespace}
316
+ host={host}
317
+ serviceName={name}
318
+ servicePort={3000}
319
+ tls={tls}
320
+ />
321
+ )
322
+
323
+ return jsx(Fragment, { children: resources_ })
324
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "composite": true
7
+ },
8
+ "include": ["src/**/*"],
9
+ "references": [
10
+ { "path": "../core" },
11
+ { "path": "../k8s-types" },
12
+ { "path": "../crds" },
13
+ { "path": "../recipes" }
14
+ ]
15
+ }