Skip to content

Commit 2ac2730

Browse files
committed
Merge remote-tracking branch 'origin/main' into bwilmoth/release-0.1.4
2 parents 7615530 + 496e9c9 commit 2ac2730

File tree

16 files changed

+3246
-2403
lines changed

16 files changed

+3246
-2403
lines changed

.github/workflows/test.yaml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- uses: actions/checkout@v3
15+
16+
- name: Install pnpm
17+
uses: pnpm/action-setup@v4
18+
with:
19+
version: 10
20+
21+
- name: Setup Node.js
22+
uses: actions/setup-node@v4
23+
with:
24+
node-version: '20'
25+
cache: 'pnpm'
26+
27+
- name: Install dependencies
28+
run: pnpm install
29+
30+
- name: Run tests
31+
run: pnpm test

dist/plugins.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export { StudioPlugin } from '../plugins/studio'
22
export { WebSocketPlugin } from '../plugins/websocket'
3+
export { SqlMacrosPlugin } from '../plugins/sql-macros'
4+
export { StripeSubscriptionPlugin } from '../plugins/stripe'

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"publish-npm-module": "npm publish --access public",
2727
"cf-typegen": "wrangler types",
2828
"delete": "wrangler delete",
29-
"prepare": "husky"
29+
"prepare": "husky",
30+
"test": "vitest"
3031
},
3132
"devDependencies": {
3233
"@cloudflare/workers-types": "^4.20241216.0",
@@ -35,6 +36,7 @@
3536
"lint-staged": "^15.2.11",
3637
"prettier": "3.4.2",
3738
"typescript": "^5.7.2",
39+
"vitest": "^2.1.8",
3840
"wrangler": "^3.96.0"
3941
},
4042
"dependencies": {

plugins/sql-macros/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# SQL Macros Plugin
2+
3+
The SQL Macros Plugin for Starbase provides SQL query validation and enhancement features to improve code quality and prevent common SQL anti-patterns.
4+
5+
## Usage
6+
7+
Add the SqlMacros plugin to your Starbase configuration:
8+
9+
```typescript
10+
import { SqlMacros } from './plugins/sql-macros'
11+
const plugins = [
12+
// ... other plugins
13+
new SqlMacros({
14+
preventSelectStar: true,
15+
}),
16+
] satisfies StarbasePlugin[]
17+
```
18+
19+
## Configuration Options
20+
21+
| Option | Type | Default | Description |
22+
| ------------------- | ------- | ------- | ---------------------------------------------------------------------------------------------- |
23+
| `preventSelectStar` | boolean | `false` | When enabled, prevents the use of `SELECT *` in queries to encourage explicit column selection |
24+
25+
## Features
26+
27+
### Prevent SELECT \*
28+
29+
When `preventSelectStar` is enabled, the plugin will raise an error if it encounters a `SELECT *` in your SQL queries. This encourages better practices by requiring explicit column selection.
30+
31+
Example of invalid query:
32+
33+
```sql
34+
SELECT * FROM users; // Will raise an error
35+
```
36+
37+
Example of valid query:
38+
39+
```sql
40+
SELECT id, username, email FROM users; // Correct usage
41+
```
42+
43+
### Exclude Columns with `$_exclude`
44+
45+
The `$_exclude` macro allows you to select all columns except the specified ones. This is useful when you want to avoid listing all columns explicitly except a few.
46+
47+
Example usage:
48+
49+
```sql
50+
SELECT $_exclude(password, secret_key) FROM users;
51+
```
52+
53+
In this example, all columns from the `users` table will be selected except for `password` and `secret_key`.

plugins/sql-macros/index.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import {
2+
StarbaseApp,
3+
StarbaseContext,
4+
StarbaseDBConfiguration,
5+
} from '../../src/handler'
6+
import { StarbasePlugin } from '../../src/plugin'
7+
import { DataSource, QueryResult } from '../../src/types'
8+
9+
const parser = new (require('node-sql-parser').Parser)()
10+
11+
export class SqlMacrosPlugin extends StarbasePlugin {
12+
config?: StarbaseDBConfiguration
13+
14+
// Prevents SQL statements with `SELECT *` from being executed
15+
preventSelectStar?: boolean
16+
17+
constructor(opts?: { preventSelectStar: boolean }) {
18+
super('starbasedb:sql-macros')
19+
this.preventSelectStar = opts?.preventSelectStar
20+
}
21+
22+
override async register(app: StarbaseApp) {
23+
app.use(async (c, next) => {
24+
this.config = c?.get('config')
25+
await next()
26+
})
27+
}
28+
29+
override async beforeQuery(opts: {
30+
sql: string
31+
params?: unknown[]
32+
dataSource?: DataSource
33+
config?: StarbaseDBConfiguration
34+
}): Promise<{ sql: string; params?: unknown[] }> {
35+
let { dataSource, sql, params } = opts
36+
37+
// A data source is required for this plugin to operate successfully
38+
if (!dataSource) {
39+
return Promise.resolve({
40+
sql,
41+
params,
42+
})
43+
}
44+
45+
sql = await this.replaceExcludeColumns(dataSource, sql, params)
46+
47+
// Prevention of `SELECT *` statements is only enforced on non-admin users
48+
// Admins should be able to continue running these statements in database
49+
// tools such as Outerbase Studio.
50+
if (this.preventSelectStar && this.config?.role !== 'admin') {
51+
sql = this.checkSelectStar(sql, params)
52+
}
53+
54+
return Promise.resolve({
55+
sql,
56+
params,
57+
})
58+
}
59+
60+
private checkSelectStar(sql: string, params?: unknown[]): string {
61+
try {
62+
const ast = parser.astify(sql)[0]
63+
64+
// Only check SELECT statements
65+
if (ast.type === 'select') {
66+
const hasSelectStar = ast.columns.some(
67+
(col: any) =>
68+
col.expr.type === 'star' ||
69+
(col.expr.type === 'column_ref' &&
70+
col.expr.column === '*')
71+
)
72+
73+
if (hasSelectStar) {
74+
throw new Error(
75+
'SELECT * is not allowed. Please specify explicit columns.'
76+
)
77+
}
78+
}
79+
80+
return sql
81+
} catch (error) {
82+
// If the error is our SELECT * error, rethrow it
83+
if (
84+
error instanceof Error &&
85+
error.message.includes('SELECT * is not allowed')
86+
) {
87+
throw error
88+
}
89+
// For parsing errors or other issues, return original SQL
90+
return sql
91+
}
92+
}
93+
94+
private async replaceExcludeColumns(
95+
dataSource: DataSource,
96+
sql: string,
97+
params?: unknown[]
98+
): Promise<string> {
99+
// Only currently works for internal data source (Durable Object SQLite)
100+
if (dataSource.source !== 'internal') {
101+
return sql
102+
}
103+
104+
// Special handling for pragma queries
105+
if (sql.toLowerCase().includes('pragma_table_info')) {
106+
return sql
107+
}
108+
109+
try {
110+
// Add semicolon if missing
111+
const normalizedSql = sql.trim().endsWith(';') ? sql : `${sql};`
112+
113+
// We allow users to write it `$_exclude` but convert it to `__exclude` so it can be
114+
// parsed with the AST library without throwing an error.
115+
const preparedSql = normalizedSql.replaceAll(
116+
'$_exclude',
117+
'__exclude'
118+
)
119+
const normalizedQuery = parser.astify(preparedSql)[0]
120+
121+
// Only process SELECT statements
122+
if (normalizedQuery.type !== 'select') {
123+
return sql
124+
}
125+
126+
// Find any columns using `__exclude`
127+
const columns = normalizedQuery.columns
128+
const excludeFnIdx = columns.findIndex(
129+
(col: any) =>
130+
col.expr &&
131+
col.expr.type === 'function' &&
132+
col.expr.name === '__exclude'
133+
)
134+
135+
if (excludeFnIdx === -1) {
136+
return sql
137+
}
138+
139+
// Get the table name from the FROM clause
140+
const tableName = normalizedQuery.from[0].table
141+
let excludedColumns: string[] = []
142+
143+
try {
144+
const excludeExpr = normalizedQuery.columns[excludeFnIdx].expr
145+
146+
// Handle both array and single argument cases
147+
const args = excludeExpr.args.value
148+
149+
// Extract column name(s) from arguments
150+
excludedColumns = Array.isArray(args)
151+
? args.map((arg: any) => arg.column)
152+
: [args.column]
153+
} catch (error: any) {
154+
console.error('Error processing exclude arguments:', error)
155+
console.error(error.stack)
156+
return sql
157+
}
158+
159+
// Query database for all columns in this table
160+
// This only works for the internal SQLite data source
161+
const schemaQuery = `
162+
SELECT name as column_name
163+
FROM pragma_table_info('${tableName}')
164+
`
165+
166+
const allColumns = (await dataSource?.rpc.executeQuery({
167+
sql: schemaQuery,
168+
})) as QueryResult[]
169+
170+
const includedColumns = allColumns
171+
.map((row: any) => row.column_name)
172+
.filter((col: string) => {
173+
const shouldInclude = !excludedColumns.includes(
174+
col.toLowerCase()
175+
)
176+
return shouldInclude
177+
})
178+
179+
// Replace the __exclude function with explicit columns
180+
normalizedQuery.columns.splice(
181+
excludeFnIdx,
182+
1,
183+
...includedColumns.map((col: string) => ({
184+
expr: { type: 'column_ref', table: null, column: col },
185+
as: null,
186+
}))
187+
)
188+
189+
// Convert back to SQL and remove trailing semicolon to maintain original format
190+
return parser.sqlify(normalizedQuery).replace(/;$/, '')
191+
} catch (error) {
192+
console.error('SQL parsing error:', error)
193+
return sql
194+
}
195+
}
196+
}

plugins/sql-macros/meta.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"version": "1.0.0",
3+
"resources": {
4+
"tables": {},
5+
"secrets": {},
6+
"variables": {}
7+
},
8+
"dependencies": {
9+
"tables": {},
10+
"secrets": {},
11+
"variables": {}
12+
}
13+
}

plugins/stripe/README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Stripe Subscriptions Plugin
2+
3+
The Stripe Subscriptions Plugin for Starbase provides a quick and simple way for applications to begin accepting product subscription payments.
4+
5+
## Usage
6+
7+
Add the StripeSubscriptionPlugin plugin to your Starbase configuration:
8+
9+
```typescript
10+
import { StripeSubscriptionPlugin } from './plugins/stripe'
11+
const plugins = [
12+
// ... other plugins
13+
new StripeSubscriptionPlugin({
14+
stripeSecretKey: 'sk_test_**********',
15+
stripeWebhookSecret: 'whsec_**********',
16+
}),
17+
] satisfies StarbasePlugin[]
18+
```
19+
20+
## Configuration Options
21+
22+
| Option | Type | Default | Description |
23+
| --------------------- | ------ | ------- | ----------------------------------------------------------------------- |
24+
| `stripeSecretKey` | string | `null` | Access your secret key from (https://dashboard.stripe.com/apikeys) |
25+
| `stripeWebhookSecret` | string | `null` | Access your signing secret from (https://dashboard.stripe.com/webhooks) |
26+
27+
## How To Use
28+
29+
### Webhook Setup
30+
31+
For our Starbase instance to receive webhook events when subscription events change, we need to add our plugin endpoint to Stripe.
32+
33+
1. Visit the Developer Webhooks page: https://dashboard.stripe.com/webhooks
34+
2. Click "+ Add Endpoint"
35+
3. Set "Endpoint URL" to `https://starbasedb.YOUR-IDENTIFIER.dev/stripe/webhook`
36+
4. Add two events to listen to:
37+
- `customer.subscription.deleted`
38+
- `checkout.session.completed`
39+
5. Save by clicking "Add Endpoint"
40+
41+
### Product Subscription Setup
42+
43+
After you create a subscription product inside of Stripe you can get the hosted link by following these steps:
44+
45+
1. Click on the "Product catalog" section
46+
2. Click on the product you want
47+
3. Click the "..." menu next to the Pricing item you want a link for
48+
4. Click "Create new payment link"
49+
50+
Now you will have a new payment URL for you to direct your users to in order for them to checkout a new subscription of your product. We will take that URL and insert it
51+
into our frontend application similar to the example below.
52+
53+
_IMPORTANT:_ You must append the `client_reference_id={userId}` at the end so we can attribute the correct user for the purchase.
54+
55+
```html
56+
<body>
57+
<a
58+
href="https://buy.stripe.com/INSERT-SUBSCRIPTION-ID?client_reference_id=INSERT-USER-ID"
59+
class="subscribe-button"
60+
>
61+
Subscribe
62+
</a>
63+
</body>
64+
```
65+
66+
Assuming all of the correct values were set in your installation phases, when a customer successfully subscribes via the Stripe Payment Link you should see a new entry automatically populate inside your SQLite table named `subscription`. At any point if this subscription is cancelled, even from the Stripe interface, the webhook will be handled via this plugin and automatically mark the `deleted_at` column with the date time it became inactive.

0 commit comments

Comments
 (0)