From 5b6f46f3e17772a61a12e999d5c2ed9382fb80fe Mon Sep 17 00:00:00 2001 From: fytgian Date: Fri, 26 Jun 2026 16:10:55 +0000 Subject: [PATCH] feat(templates): implement stream template system (#596) - templateService: in-memory CRUD, createFromStream (strips employer/employee), share toggle, owner-scoped list that includes shared+default templates, seeded default templates (Monthly Salary USDC, Weekly Contractor USDC) - streamTemplates router: POST /stream-templates (create), GET (list), GET /:id, PATCH /:id (update), DELETE /:id, POST /:id/share, POST /:id/create-stream (instantiate with optional overrides) - server.js: mount /v1/api/stream-templates behind authMiddleware --- api/routes/streamTemplates.js | 228 ++++++++++++++++++++++++++++++++ api/server.js | 4 + api/services/templateService.js | 110 +++++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 api/routes/streamTemplates.js create mode 100644 api/services/templateService.js diff --git a/api/routes/streamTemplates.js b/api/routes/streamTemplates.js new file mode 100644 index 0000000..690766d --- /dev/null +++ b/api/routes/streamTemplates.js @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Stream Template routes (#596) + * + * POST /stream-templates — create template + * POST /stream-templates/from-stream — save existing stream params as template + * GET /stream-templates — list (own + shared + defaults) + * GET /stream-templates/:id — get single template + * PATCH /stream-templates/:id — update template + * DELETE /stream-templates/:id — delete template + * POST /stream-templates/:id/share — toggle sharing + * POST /stream-templates/:id/create-stream — create stream from template + */ + +const express = require('express'); +const { body, param, validationResult } = require('express-validator'); +const templateService = require('../services/templateService'); +const stellarService = require('../services/stellarService'); +const router = express.Router(); + +const STREAM_PARAMS = [ + body('params.token_address').isString().matches(/^[A-Z0-9]{56}$/).withMessage('Invalid token address'), + body('params.deposit').isString().matches(/^[0-9]+$/).withMessage('Invalid deposit'), + body('params.rate_per_second').isString().matches(/^[0-9]+$/).withMessage('Invalid rate_per_second'), + body('params.stop_time').isInt({ min: 0 }), + body('params.cooldown_period').isInt({ min: 0 }), + body('params.cliff_time').isInt({ min: 0 }), +]; + +function validate(req, res) { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).json({ error: 'Validation failed', details: errors.array() }); + return false; + } + return true; +} + +function ownerId(req) { + return req.stellarAddress || req.apiKey || 'unknown'; +} + +/** + * @swagger + * /stream-templates: + * post: + * summary: Create a stream template + * tags: [Stream Templates] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [name, params] + * properties: + * name: { type: string } + * shared: { type: boolean, default: false } + * params: + * type: object + * required: [token_address, deposit, rate_per_second, stop_time, cooldown_period, cliff_time] + * responses: + * 201: + * description: Template created + */ +router.post('/', [ + body('name').isString().notEmpty().trim(), + body('shared').optional().isBoolean(), + ...STREAM_PARAMS, +], (req, res) => { + if (!validate(req, res)) return; + const tmpl = templateService.create({ ownerId: ownerId(req), name: req.body.name, params: req.body.params, shared: req.body.shared }); + res.status(201).json({ success: true, template: tmpl }); +}); + +/** + * @swagger + * /stream-templates/from-stream: + * post: + * summary: Save stream parameters as a template + * tags: [Stream Templates] + */ +router.post('/from-stream', [ + body('name').isString().notEmpty().trim(), + body('streamParams').isObject(), + body('streamParams.token_address').isString(), + body('streamParams.deposit').isString().matches(/^[0-9]+$/), + body('streamParams.rate_per_second').isString().matches(/^[0-9]+$/), +], (req, res) => { + if (!validate(req, res)) return; + const tmpl = templateService.createFromStream(ownerId(req), req.body.name, req.body.streamParams); + res.status(201).json({ success: true, template: tmpl }); +}); + +/** + * @swagger + * /stream-templates: + * get: + * summary: List templates (own + shared + defaults) + * tags: [Stream Templates] + * responses: + * 200: + * description: Array of templates + */ +router.get('/', (req, res) => { + res.json({ success: true, templates: templateService.list(ownerId(req)) }); +}); + +/** + * @swagger + * /stream-templates/{id}: + * get: + * summary: Get a single template + * tags: [Stream Templates] + */ +router.get('/:id', [param('id').isUUID()], (req, res) => { + if (!validate(req, res)) return; + const tmpl = templateService.get(req.params.id, ownerId(req)); + if (!tmpl) return res.status(404).json({ error: 'Template not found' }); + res.json({ success: true, template: tmpl }); +}); + +/** + * @swagger + * /stream-templates/{id}: + * patch: + * summary: Update a template + * tags: [Stream Templates] + */ +router.patch('/:id', [ + param('id').isUUID(), + body('name').optional().isString().notEmpty().trim(), + body('shared').optional().isBoolean(), +], (req, res) => { + if (!validate(req, res)) return; + const tmpl = templateService.update(req.params.id, ownerId(req), req.body); + if (!tmpl) return res.status(404).json({ error: 'Template not found or not editable' }); + res.json({ success: true, template: tmpl }); +}); + +/** + * @swagger + * /stream-templates/{id}: + * delete: + * summary: Delete a template + * tags: [Stream Templates] + */ +router.delete('/:id', [param('id').isUUID()], (req, res) => { + if (!validate(req, res)) return; + const deleted = templateService.remove(req.params.id, ownerId(req)); + if (!deleted) return res.status(404).json({ error: 'Template not found or not deletable' }); + res.json({ success: true, message: 'Template deleted' }); +}); + +/** + * @swagger + * /stream-templates/{id}/share: + * post: + * summary: Toggle sharing of a template + * tags: [Stream Templates] + */ +router.post('/:id/share', [ + param('id').isUUID(), + body('shared').isBoolean(), +], (req, res) => { + if (!validate(req, res)) return; + const tmpl = templateService.share(req.params.id, ownerId(req), req.body.shared); + if (!tmpl) return res.status(404).json({ error: 'Template not found or not shareable' }); + res.json({ success: true, template: tmpl }); +}); + +/** + * @swagger + * /stream-templates/{id}/create-stream: + * post: + * summary: Create a stream from a template + * tags: [Stream Templates] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [employer, employee] + * properties: + * employer: { $ref: '#/components/schemas/Address' } + * employee: { $ref: '#/components/schemas/Address' } + * overrides: + * type: object + * description: Optional param overrides (e.g. deposit, rate_per_second) + */ +router.post('/:id/create-stream', [ + param('id').isUUID(), + body('employer').isString().matches(/^G[A-Z0-9]{55}$/), + body('employee').isString().matches(/^G[A-Z0-9]{55}$/), + body('overrides').optional().isObject(), +], async (req, res, next) => { + if (!validate(req, res)) return; + try { + const tmpl = templateService.get(req.params.id, ownerId(req)); + if (!tmpl) return res.status(404).json({ error: 'Template not found' }); + + const { employer, employee, overrides = {} } = req.body; + const params = { ...tmpl.params, ...overrides }; + + const result = await stellarService.submitContractTransaction({ + sourceKey: employer, + contractId: stellarService.streamContractId, + functionName: 'create_stream', + args: [ + new stellarService.rpc.Address(employer), + new stellarService.rpc.Address(employee), + new stellarService.rpc.Address(params.token_address), + BigInt(params.deposit), + BigInt(params.rate_per_second), + params.stop_time, + params.cooldown_period, + params.cliff_time, + ], + }); + + res.status(201).json({ success: true, stream_id: result.result, transaction_hash: result.hash }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/api/server.js b/api/server.js index 5baf847..ec21c7c 100644 --- a/api/server.js +++ b/api/server.js @@ -35,6 +35,7 @@ const tokenRoutes = require('./routes/tokens'); const adminRoutes = require('./routes/admin'); const governanceRoutes = require('./routes/governance'); const userRoutes = require('./routes/users'); +const streamTemplateRoutes = require('./routes/streamTemplates'); const app = express(); const PORT = process.env.PORT || 3000; @@ -364,6 +365,9 @@ app.post('/streams/:id/withdraw', (req, res) => { // Auth routes (public — no authMiddleware) app.use('/auth', authRoutes); +// Stream template routes +app.use('/v1/api/stream-templates', authMiddleware, streamTemplateRoutes); + // v1 API routes (current) app.use('/v1/api/streams', authMiddleware, streamRoutes); app.use('/v1/api/tokens', authMiddleware, tokenRoutes); diff --git a/api/services/templateService.js b/api/services/templateService.js new file mode 100644 index 0000000..bbf4946 --- /dev/null +++ b/api/services/templateService.js @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Stream Template Service (#596) + * + * In-memory store (replace with DB-backed store in production). + * Supports: create, list, get, update, delete, share with team, default templates. + */ + +const crypto = require('crypto'); + +// template: { id, ownerId, name, params, shared, isDefault, createdAt, updatedAt } +const templates = new Map(); + +// Built-in default templates seeded at module load +const DEFAULTS = [ + { + name: 'Monthly Salary (USDC)', + params: { + token_address: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + deposit: '2592000000000', // 30 days × 86400s × 1 USDC/s in stroops + rate_per_second: '1000000', // 1 USDC/s (7 decimals) + stop_time: 0, + cooldown_period: 0, + cliff_time: 0, + }, + }, + { + name: 'Weekly Contractor (USDC)', + params: { + token_address: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5', + deposit: '604800000000', // 7 days × 86400s × 1 USDC/s + rate_per_second: '1000000', + stop_time: 0, + cooldown_period: 86400, // 1-day cooldown + cliff_time: 0, + }, + }, +]; + +for (const d of DEFAULTS) { + const id = crypto.randomUUID(); + templates.set(id, { + id, + ownerId: 'system', + name: d.name, + params: d.params, + shared: true, + isDefault: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); +} + +function create({ ownerId, name, params, shared = false }) { + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + const tmpl = { id, ownerId, name, params, shared, isDefault: false, createdAt: now, updatedAt: now }; + templates.set(id, tmpl); + return tmpl; +} + +function createFromStream(ownerId, name, streamParams) { + // Strips runtime-only fields (employer/employee) — store only the reusable config + const { employer: _e, employee: _emp, ...params } = streamParams; + return create({ ownerId, name, params }); +} + +function list(ownerId) { + const result = []; + for (const [, t] of templates) { + if (t.ownerId === ownerId || t.shared) result.push(t); + } + return result; +} + +function get(id, ownerId) { + const t = templates.get(id); + if (!t) return null; + if (t.ownerId !== ownerId && !t.shared) return null; + return t; +} + +function update(id, ownerId, patch) { + const t = templates.get(id); + if (!t) return null; + if (t.ownerId !== ownerId || t.isDefault) return null; + const allowed = ['name', 'params', 'shared']; + for (const k of allowed) { + if (patch[k] !== undefined) t[k] = patch[k]; + } + t.updatedAt = new Date().toISOString(); + return t; +} + +function remove(id, ownerId) { + const t = templates.get(id); + if (!t || t.ownerId !== ownerId || t.isDefault) return false; + templates.delete(id); + return true; +} + +function share(id, ownerId, shared) { + const t = templates.get(id); + if (!t || t.ownerId !== ownerId || t.isDefault) return null; + t.shared = shared; + t.updatedAt = new Date().toISOString(); + return t; +} + +module.exports = { create, createFromStream, list, get, update, remove, share };