AgentStack
SKILL verified MIT Self-run

Br Resource Table

skill-lonsdale201-wp-agent-skills-br-resource-table · by Lonsdale201

Create CRUD endpoints over a custom database table via

No reviews yet
0 installs
7 views
0.0% view→install

Install

$ agentstack add skill-lonsdale201-wp-agent-skills-br-resource-table

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access Used
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Br Resource Table? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

better-route: Custom-table-backed Resource CRUD

For developers generating REST CRUD over a custom database table (audit logs, analytics events, plugin-specific entities) using better-route's Resource API. Same fluent builder as CPT resources, but with two stricter rules — deny-by-default for all actions, and explicit fields() required.

Misconception this skill corrects

> "I set up a Resource::make('audit')->sourceTable('wp_myapp_events')->register($router) and the list endpoint returns 403 even for admins. Must be a permission cache issue."

It's not a cache issue. Verified at [src/Resource/Resource.php:835-844](Resource.php) — the defaultPermissionForAction for table-source resources returns false for every action (list, get, create, update, delete) when no ->policy() is declared:

return match ($action) {
    'list', 'get' => $this->sourceCpt !== null
        ? static fn (): bool => true
        : static fn (): bool => false,    // ← table reads deny by default
    default       => static fn (): bool => false,
};

The rationale: a CPT inherits WordPress's visibility model (post_status, capability mapping, public/private logic) so reads are reasonable to allow by default. A raw custom table has no such model — better-route deliberately refuses to guess and forces the developer to declare intent.

The fix:

Resource::make('audit')
    ->sourceTable('wp_myapp_events')
    ->fields(['id', 'event_type', 'user_id', 'created_at'])
    ->policy(ResourcePolicy::publicReadPrivateWrite('manage_options'))
    ->register($router);

Other AI-prone misconceptions:

  • "I'll skip fields([...]) since the table has obvious columns the resource can introspect." Wrong — table-source requires explicit fields(). Verified at [Resource.php:514-517](Resource.php): if ($fields === []) throw new InvalidArgumentException('fields are required for sourceTable resources.'). Better-route doesn't introspect schemas; you list the exposed columns.
  • "Custom table resources support the same cptVisibleStatuses for soft-delete." Wrong — cptVisibleStatuses and cptVisibilityPolicy are CPT-only. Custom tables have no post_status concept. For soft-delete on a custom table, model it explicitly: add a deleted_at column, exclude rows via repository logic.
  • "->sourceTable('wp_myapp_events') works with cross-database tables." Wrong — table names with . (cross-database references) are rejected by the underlying WpdbIdempotencyStore and table dispatcher to prevent cross-DB writes.

When to use this skill

Trigger when ANY of the following is true:

  • The diff calls Resource::make(...)->sourceTable(...).
  • The user asks "how do I expose a custom DB table over REST" / "CRUD endpoints for wp_myapp_*".
  • Reviewing a PR with Resource::make + sourceTable.
  • Triaging "Resource returns 403 on every call" after a v0.3.0 upgrade — table policy is the missing piece.

Workflow

1. Minimal table resource

add_action('rest_api_init', function () {
    $router = \BetterRoute\BetterRoute::router('myapp', 'v1');

    \BetterRoute\Resource\Resource::make('audit_events')
        ->restNamespace('myapp/v1')
        ->sourceTable('wp_myapp_audit_events')
        ->fields(['id', 'event_type', 'user_id', 'created_at'])
        ->filters(['event_type', 'user_id'])
        ->sort(['created_at', 'id'])
        ->policy(\BetterRoute\Resource\ResourcePolicy::adminOnly('manage_options'))
        ->register($router);

    $router->register();
});

This generates:

  • GET /myapp/v1/audit_events — list
  • GET /myapp/v1/audit_events/{id} — get one
  • POST /myapp/v1/audit_events — create
  • PUT /myapp/v1/audit_events/{id} — update
  • PATCH /myapp/v1/audit_events/{id} — partial update
  • DELETE /myapp/v1/audit_events/{id} — delete

(Use ->allow(['list', 'get']) to restrict — see step 4.)

2. Pass the unprefixed table name? Or the prefixed?

->sourceTable('wp_myapp_events') — pass the FULL table name as it appears in $wpdb. Better-route does NOT auto-prefix:

global $wpdb;
$tableName = $wpdb->prefix . 'myapp_events';   // e.g. 'wp_myapp_events' or 'wp_2_myapp_events' on multisite
->sourceTable($tableName)

If you hardcode 'wp_myapp_events', your resource breaks on multisite installs where $wpdb->prefix is 'wp_2_'. Use $wpdb->prefix to compose the full name at registration time.

3. Custom primary key

->sourceTable('wp_myapp_events', 'event_id')   // second arg = primary-key column name

Default is 'id'. The primary key drives URL routing (/audit_events/{id} looks up by event_id column when configured).

4. Restrict actions

Resource::make('audit_events')
    ->sourceTable('wp_myapp_events')
    ->fields(['id', 'event_type', 'user_id', 'created_at'])
    ->allow(['list', 'get'])      // read-only — no create/update/delete routes registered
    ->policy(ResourcePolicy::adminOnly('manage_options'))
    ->register($router);

For event logs / read-only feeds, restrict to ['list', 'get']. Don't register write routes that would never be exercised.

5. Sort default

If you don't declare ->sort([...]), the default sort is by primary key ([Resource.php:524](Resource.php) — allowedSort: $this->sort !== [] ? $this->sort : [$primaryKey]). For chronological data, declare 'created_at' first:

->sort(['-created_at', 'id'])   // newest-first by default

6. Pagination

->defaultPerPage(20)     // default page size
->maxPerPage(100)        // hard cap
->maxOffset(10000)       // prevent deep-pagination DoS (LIMIT N OFFSET 50000 is expensive)

The list response shape matches the CPT resource's:

{
  "data": [ /* rows */ ],
  "meta": { "page": 1, "perPage": 20, "total": 1234 }
}

(uniformEnvelope(true) applies the same {data, meta} wrapper to single-record responses.)

7. Custom repository for derived tables

->usingTableRepository(new MyJoinedTableRepository())

The default WordPressTableRepository does single-table CRUD via $wpdb. For derived data (joins, aggregates), implement TableRepositoryInterface ([src/Resource/Table/TableRepositoryInterface.php](TableRepositoryInterface.php)). Most plugins don't need this.

8. Write validation

->writeSchema([
    'event_type' => ['type' => 'string', 'required' => true, 'enum' => ['values' => ['login', 'logout', 'failed_attempt']]],
    'user_id'    => ['type' => 'int', 'min' => 0, 'nullable' => true],
    'created_at' => ['type' => 'date', 'required' => true],
])

Same syntax as CPT resources — see br-write-schema for the full catalog.

Critical rules

  • Custom-table resources are deny-by-default for ALL actions (including list and get). Without ->policy(), every call returns 403. Verified at [Resource.php:840-844](Resource.php).
  • ->fields([...]) is REQUIRED for table sources. Throws InvalidArgumentException otherwise.
  • ->restNamespace() is REQUIRED. Same as CPT.
  • Pass the full prefixed table name — better-route does NOT add $wpdb->prefix. Use $wpdb->prefix . 'myapp_events' to be multisite-safe.
  • Cross-database table names (containing .) are rejected — security guard against accidental cross-DB writes.
  • Default sort is the primary key when ->sort([]) is empty. Declare an explicit sort for chronological / business-meaningful ordering.
  • cptVisibleStatuses / cptVisibilityPolicy don't apply. Tables have no post_status. Implement soft-delete via a column + repository filter.
  • deleteMode('trash') doesn't apply — there's no trash for custom tables. deleteMode('force') is the only valid value (the default).
  • ->allow([...]) is the right way to make read-only resources — don't try to deny writes via empty policy callbacks; restrict at registration.

Common mistakes

// WRONG — no policy declared
Resource::make('audit')->sourceTable('wp_myapp_events')
    ->fields(['id', 'event_type'])
    ->register($router);
// → list / get / create / update / delete ALL return 403

// RIGHT — declare a policy
->policy(ResourcePolicy::adminOnly('manage_options'))

// WRONG — no fields declared
Resource::make('audit')->sourceTable('wp_myapp_events')
    ->policy(ResourcePolicy::adminOnly())
    ->register($router);
// → throws InvalidArgumentException: 'fields are required for sourceTable resources.'

// RIGHT
->fields(['id', 'event_type', 'user_id', 'created_at'])

// WRONG — hardcoded table name without prefix
->sourceTable('myapp_events')   // WP looks for actual table 'myapp_events' (no wp_ prefix); usually doesn't exist

// RIGHT
global $wpdb;
->sourceTable($wpdb->prefix . 'myapp_events')

// WRONG — assuming CPT visibility methods work on tables
Resource::make('events')->sourceTable($wpdb->prefix . 'events')
    ->cptVisibleStatuses(['active'])   // 🔴 method exists but does nothing for table-source
    ->register($router);

// RIGHT — implement soft-delete in the table itself
// Add a 'status' column, filter via filterSchema:
->fields(['id', 'name', 'status'])
->filters(['status'])
->filterSchema(['status' => ['type' => 'enum', 'enum' => ['values' => ['active']]]])
// Or use ->usingTableRepository(...) with a custom repo that always filters status='active' on reads.

// WRONG — cross-database table reference
->sourceTable('other_db.wp_myapp_events')   // 🔴 rejected by guard

// RIGHT — same database
->sourceTable($wpdb->prefix . 'myapp_events')

// WRONG — empty sort + numeric primary key on a chronological log
->sourceTable($wpdb->prefix . 'audit_log')
->fields(['id', 'created_at', 'event_type'])
// Default sort is 'id' — fine for primary-key fetch, but a list scroll feels random.

// RIGHT
->sort(['-created_at', 'id'])   // newest-first; id as tiebreaker

// WRONG — hoping deleteMode('trash') gives soft-delete on tables
->deleteMode('trash')   // 🔴 throws or no-ops; trash is CPT-only

// RIGHT — soft-delete via column + repository filter
// Add 'deleted_at' nullable column, filter rows where deleted_at IS NULL, set deleted_at on DELETE.
// Implement via a custom TableRepository.

// WRONG — assuming the resource introspects the table schema for fields
->sourceTable('wp_myapp_events')
// (no fields() call)
// Hopes resource discovers columns via DESCRIBE — it does not. Throws.

// RIGHT — explicit fields list
->fields(['id', 'event_type', 'user_id', 'payload', 'created_at'])

Cross-references

  • Run br-resource-cpt for the CPT-source equivalent — same fluent builder, different default-permission semantics.
  • Run br-resource-policy for the policy preset reference — adminOnly, publicReadPrivateWrite, capabilities, callbacks.
  • Run br-write-schema for the validation rule catalog used in ->writeSchema(...).
  • Run br-install-and-migrate if you're upgrading from usingTableRepository(...)` with a custom repo.
  • Row-level access control (e.g. "user X can only see rows where user_id = X"). Use ->fieldPolicy or, for whole-row gating, a policy(ResourcePolicy::callbacks(...)) that reads the request and rejects.
  • Bulk operations / batch writes. Resources are single-row; bulk is a consumer concern.
  • Real-time subscriptions / SSE / WebSocket. Out of scope.

References

  • Resource builder: [libraries/better-route/src/Resource/Resource.php:95-100](Resource.php) — sourceTable(string $table, string $primaryKey = 'id').
  • Required-field guard: [Resource.php:512-517](Resource.php) — if ($fields === []) throw new InvalidArgumentException('fields are required for sourceTable resources.').
  • Default permission for table source: [Resource.php:835-844](Resource.php) — defaultPermissionForAction returns false for table-source on every action.
  • Default sort fallback: [Resource.php:524](Resource.php) — allowedSort: $this->sort !== [] ? $this->sort : [$primaryKey].
  • Table dispatcher: [libraries/better-route/src/Resource/Table/](Table/) — WordPressTableRepository, TableListQuery, TableListQueryParser.
  • ResourcePolicy presets: [libraries/better-route/src/Resource/ResourcePolicy.php](ResourcePolicy.php).

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.