Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,54 @@ jobs:
coverage/coverage-summary.json
coverage/lcov.info

test-external-api-database:
name: External API DB migrations (${{ matrix.engine }})
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- engine: mariadb
image: mariadb:10.3
- engine: mysql
image: mysql:8.0
services:
database:
image: ${{ matrix.image }}
env:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: youtarr
ports:
- 3321:3306
options: >-
--health-cmd="mysqladmin ping --protocol=tcp -h 127.0.0.1 -uroot -prootpass"
--health-interval=5s
--health-timeout=5s
--health-retries=20
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '20.x'
cache: 'npm'
- name: Pin npm
run: npm install -g npm@11.15.0 --ignore-scripts
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Run external API migration lifecycle
run: npm run test:backend -- migrations/__tests__/externalApiDatabase.integration.test.js --runInBand
env:
EXTERNAL_API_DATABASE_TEST: 'true'
DB_HOST: 127.0.0.1
DB_PORT: 3321
DB_USER: root
DB_PASSWORD: rootpass
DB_ADMIN_USER: root
DB_ADMIN_PASSWORD: rootpass

test-frontend:
name: Frontend Tests
runs-on: ubuntu-latest
Expand Down Expand Up @@ -331,7 +379,7 @@ jobs:
check-all:
name: All Checks
runs-on: ubuntu-latest
needs: [lint, test-backup-restore, test-cookie-validation, test-backend, test-frontend, test-storybook, security-audit, docs]
needs: [lint, test-backup-restore, test-cookie-validation, test-backend, test-external-api-database, test-frontend, test-storybook, security-audit, docs]
if: always()
steps:
- name: Check all results
Expand All @@ -341,6 +389,7 @@ jobs:
echo "Backup/Restore Tests: ${{ needs.test-backup-restore.result }}"
echo "Cookie Loader Tests: ${{ needs.test-cookie-validation.result }}"
echo "Backend Tests: ${{ needs.test-backend.result }}"
echo "External API Database Tests: ${{ needs.test-external-api-database.result }}"
echo "Frontend Tests: ${{ needs.test-frontend.result }}"
echo "Storybook Tests: ${{ needs.test-storybook.result }}"
echo "Security Audit: ${{ needs.security-audit.result }}"
Expand All @@ -350,6 +399,7 @@ jobs:
[ "${{ needs.test-backup-restore.result }}" != "success" ] || \
[ "${{ needs.test-cookie-validation.result }}" != "success" ] || \
[ "${{ needs.test-backend.result }}" != "success" ] || \
[ "${{ needs.test-external-api-database.result }}" != "success" ] || \
[ "${{ needs.test-frontend.result }}" != "success" ] || \
[ "${{ needs.test-storybook.result }}" != "success" ] || \
[ "${{ needs.security-audit.result }}" != "success" ] || \
Expand All @@ -362,6 +412,7 @@ jobs:
[ "${{ needs.test-backup-restore.result }}" != "success" ] && echo " - Backup/Restore Tests"
[ "${{ needs.test-cookie-validation.result }}" != "success" ] && echo " - Cookie Loader Tests"
[ "${{ needs.test-backend.result }}" != "success" ] && echo " - Backend Tests"
[ "${{ needs.test-external-api-database.result }}" != "success" ] && echo " - External API Database Tests"
[ "${{ needs.test-frontend.result }}" != "success" ] && echo " - Frontend Tests"
[ "${{ needs.test-storybook.result }}" != "success" ] && echo " - Storybook Tests"
[ "${{ needs.security-audit.result }}" != "success" ] && echo " - Security Audit"
Expand Down
6 changes: 6 additions & 0 deletions docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ For deployments behind external authentication or not exposed to the internet:

API Keys provide persistent authentication for external integrations like bookmarklets, mobile shortcuts, and automation tools.

### External API policy foundation

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section describes fields that nothing uses yet, so users reading the auth docs can't do anything with it. Can you move it to docs/DATABASE.md instead? The table list there (around line 32) should also get the 3 new tables, since that's where people look to see what's in the database.

The database now stores policy metadata for future versioned external API access without enabling a public endpoint. Existing keys are backfilled with the `legacy_download` role and retain their current single-video behavior. External roles use explicit permissions and channel grants; `admin` is the broad role name. Keys may also carry approval, rating/media, quota, and revocation metadata. These fields are inert until the external API control plane is enabled.

The schema and migration history are authoritative for field details. API-key values remain hashed and are never recoverable from the database; revocation is represented by `revoked_at` and inactive status.

### Key Features
- **Persistent**: No expiration (unlike session tokens)
- **Scoped**: Limited to single video downloads only
Expand Down
40 changes: 40 additions & 0 deletions migrations/20260908100000-add-external-api-key-policy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use strict';
const { addColumnIfMissing, removeColumnIfExists } = require('./helpers');
module.exports = {
async up(q, S) {
const cols = [
['role', { type: S.STRING(32), allowNull: false, defaultValue: 'legacy_download' }],
Comment thread
bballdavis marked this conversation as resolved.
['auto_approve_video_requests', { type: S.BOOLEAN, allowNull: false, defaultValue: false }],
['auto_approve_channel_requests', { type: S.BOOLEAN, allowNull: false, defaultValue: false }],
['auto_approve_delete_requests', { type: S.BOOLEAN, allowNull: false, defaultValue: false }],
['max_rating_level', { type: S.INTEGER, allowNull: false, defaultValue: 4 }],
['allow_unrated', { type: S.BOOLEAN, allowNull: false, defaultValue: false }],
['allowed_media_types', { type: S.JSON, allowNull: true, defaultValue: null }],
['revoked_at', { type: S.DATE, allowNull: true, defaultValue: null }],
['allow_video_requests', { type: S.BOOLEAN, allowNull: true, defaultValue: null }],
['allow_channel_requests', { type: S.BOOLEAN, allowNull: true, defaultValue: null }],
['allow_delete_video_requests', { type: S.BOOLEAN, allowNull: true, defaultValue: null }],
['max_active_jobs', { type: S.INTEGER, allowNull: false, defaultValue: 5 }],
['hourly_write_limit', { type: S.INTEGER, allowNull: false, defaultValue: 30 }],
['daily_write_limit', { type: S.INTEGER, allowNull: false, defaultValue: 200 }],
];
for (const [name, definition] of cols) await addColumnIfMissing(q, 'apikeys', name, definition);
await q.sequelize.query("UPDATE apikeys SET role = 'legacy_download' WHERE role IS NULL OR role = ''");
await q.sequelize.query("UPDATE apikeys SET allowed_media_types = '[\"video\"]' WHERE allowed_media_types IS NULL");
await q.changeColumn('apikeys', 'allowed_media_types', { type: S.JSON, allowNull: false });
for (const [column, roles] of [['allow_video_requests', "'request', 'delete', 'admin'"], ['allow_channel_requests', "'request', 'delete', 'admin'"], ['allow_delete_video_requests', "'delete', 'admin'"]]) {
await q.sequelize.query(`UPDATE apikeys SET ${column} = CASE WHEN role IN (${roles}) THEN true ELSE false END WHERE ${column} IS NULL`);
await q.changeColumn('apikeys', column, { type: S.BOOLEAN, allowNull: false, defaultValue: false });
}
},
async down(q) {
const columns = await q.describeTable('apikeys');
if (columns.role && columns.is_active) {
const revokedAt = columns.revoked_at
? ', revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP)'
: '';
await q.sequelize.query(`UPDATE apikeys SET is_active = false${revokedAt} WHERE role IS NOT NULL AND role <> 'legacy_download'`);
}
for (const name of ['daily_write_limit', 'hourly_write_limit', 'max_active_jobs', 'allow_delete_video_requests', 'allow_channel_requests', 'allow_video_requests', 'revoked_at', 'allowed_media_types', 'allow_unrated', 'max_rating_level', 'auto_approve_delete_requests', 'auto_approve_channel_requests', 'auto_approve_video_requests', 'role']) await removeColumnIfExists(q, 'apikeys', name);
},
};
15 changes: 15 additions & 0 deletions migrations/20260908101000-create-api-key-channel-grants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use strict';
const { createTableIfNotExists, dropTableIfExists, addIndexIfMissing } = require('./helpers');
module.exports = {
async up(q, S) {
await createTableIfNotExists(q, 'api_key_channel_grants', {
id: { type: S.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false },
api_key_id: { type: S.INTEGER, allowNull: false, references: { model: 'apikeys', key: 'id' }, onUpdate: 'CASCADE', onDelete: 'CASCADE' },
channel_id: { type: S.INTEGER, allowNull: false, references: { model: 'channels', key: 'id' }, onUpdate: 'CASCADE', onDelete: 'CASCADE' },
created_at: { type: S.DATE, allowNull: false, defaultValue: S.NOW },
}, { charset: 'utf8mb4', collate: 'utf8mb4_unicode_ci' });
await addIndexIfMissing(q, 'api_key_channel_grants', ['api_key_id', 'channel_id'], { unique: true, name: 'api_key_channel_grants_key_channel_uq' });
await addIndexIfMissing(q, 'api_key_channel_grants', ['channel_id'], { name: 'api_key_channel_grants_channel_idx' });
},
async down(q) { await dropTableIfExists(q, 'api_key_channel_grants'); },
};
47 changes: 47 additions & 0 deletions migrations/20260908102000-create-external-requests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use strict';
const { createTableIfNotExists, dropTableIfExists, addIndexIfMissing } = require('./helpers');
module.exports = {
async up(q, S) {
await createTableIfNotExists(q, 'external_requests', {
id: { type: S.UUID, primaryKey: true, allowNull: false },
api_key_id: {
type: S.INTEGER,
allowNull: false,
references: { model: 'apikeys', key: 'id' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
channel_id: {
type: S.INTEGER,
allowNull: true,
references: { model: 'channels', key: 'id' },
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
youtube_id: { type: S.STRING(32), allowNull: true },
channel_url: { type: S.STRING(500), allowNull: true },
grant_to_requesting_key: { type: S.BOOLEAN, allowNull: true },
request_type: { type: S.STRING(20), allowNull: false, defaultValue: 'video' },
status: { type: S.STRING(20), allowNull: false, defaultValue: 'pending' },
active_dedupe_key: { type: S.STRING(191), allowNull: true },
idempotency_hash: { type: S.STRING(64), allowNull: true },
job_id: {
type: S.UUID,
allowNull: true,
references: { model: 'jobs', key: 'id' },
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
message: { type: S.STRING(500), allowNull: true },
created_at: { type: S.DATE, allowNull: false, defaultValue: S.NOW },
updated_at: { type: S.DATE, allowNull: false, defaultValue: S.NOW },
decided_at: { type: S.DATE, allowNull: true },
completed_at: { type: S.DATE, allowNull: true },
}, { charset: 'utf8mb4', collate: 'utf8mb4_unicode_ci' });
await addIndexIfMissing(q, 'external_requests', ['active_dedupe_key'], { unique: true, name: 'external_requests_active_dedupe_uq' });
await addIndexIfMissing(q, 'external_requests', ['api_key_id', 'idempotency_hash'], { unique: true, name: 'external_requests_key_idempotency_uq' });
await addIndexIfMissing(q, 'external_requests', ['api_key_id', 'created_at'], { name: 'external_requests_key_created_idx' });
await addIndexIfMissing(q, 'external_requests', ['api_key_id', 'status'], { name: 'external_requests_key_status_idx' });
},
async down(q) { await dropTableIfExists(q, 'external_requests'); },
};
24 changes: 24 additions & 0 deletions migrations/20260908106000-create-external-api-usage-buckets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict';
const { createTableIfNotExists, dropTableIfExists, addIndexIfMissing } = require('./helpers');
module.exports = {
async up(q, S) {
await createTableIfNotExists(q, 'external_api_usage_buckets', {
id: { type: S.BIGINT, primaryKey: true, autoIncrement: true, allowNull: false },
api_key_id: {
type: S.INTEGER,
allowNull: false,
references: { model: 'apikeys', key: 'id' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
window_type: { type: S.STRING(8), allowNull: false },
window_start: { type: S.DATE, allowNull: false },
accepted_writes: { type: S.INTEGER, allowNull: false, defaultValue: 0 },
created_at: { type: S.DATE, allowNull: false, defaultValue: S.NOW },
updated_at: { type: S.DATE, allowNull: false, defaultValue: S.NOW },
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other two new tables pass { charset: 'utf8mb4', collate: 'utf8mb4_unicode_ci' } to createTable, but this one doesn't. It works today, because an earlier migration already sets the database default, but can you add it here too so all the new tables match?

await addIndexIfMissing(q, 'external_api_usage_buckets', ['api_key_id', 'window_type', 'window_start'], { unique: true, name: 'external_api_usage_key_window_uq' });
await addIndexIfMissing(q, 'external_api_usage_buckets', ['window_start'], { name: 'external_api_usage_window_idx' });
},
async down(q) { await dropTableIfExists(q, 'external_api_usage_buckets'); },
};
Loading
Loading