pgkiln 0.47.0 → 0.49.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.
Files changed (69) hide show
  1. package/SECURITY.md +2 -2
  2. package/db/migrations/109_builder_pins.sql +19 -0
  3. package/db/migrations/110_sql_charts.sql +23 -0
  4. package/db/migrations/111_sql_diagram.sql +24 -0
  5. package/db/migrations/113_rest_objects.sql +49 -0
  6. package/db/migrations/116_dump_jobs.sql +41 -0
  7. package/db/migrations/117_rest_handlers.sql +63 -0
  8. package/db/migrations/118_change_log.sql +54 -0
  9. package/docs/apex-feature-parity.md +11 -11
  10. package/docs/guide/01-installation.md +4 -0
  11. package/docs/guide/03-builder.md +267 -7
  12. package/docs/guide/08-security.md +29 -0
  13. package/docs/guide/12-development.md +70 -6
  14. package/docs/guide/13-rest-api.md +133 -10
  15. package/docs/guide/15-extensions.md +1 -1
  16. package/examples/hr/hr_62_rest_module.sql +39 -0
  17. package/package.json +1 -1
  18. package/public/app.css +1 -1
  19. package/public/builder-icons.svg +2 -0
  20. package/public/builder.css +442 -173
  21. package/public/builder.js +48 -0
  22. package/public/data-modeler.css +56 -0
  23. package/public/data-modeler.js +367 -0
  24. package/public/palette.js +133 -0
  25. package/public/sql-monitor.js +73 -0
  26. package/public/worksheet.css +49 -0
  27. package/public/worksheet.js +4 -0
  28. package/src/app.ts +10 -0
  29. package/src/automations.ts +6 -0
  30. package/src/binds.ts +2 -1
  31. package/src/builder/advisor.ts +24 -2
  32. package/src/builder/autorest.ts +363 -0
  33. package/src/builder/changelog.ts +240 -0
  34. package/src/builder/components.ts +1 -1
  35. package/src/builder/data-modeler.ts +382 -0
  36. package/src/builder/datapump.ts +364 -0
  37. package/src/builder/db-users.ts +472 -0
  38. package/src/builder/ddl-tools.ts +13 -4
  39. package/src/builder/find-workspace.ts +2 -2
  40. package/src/builder/launchpad.ts +140 -0
  41. package/src/builder/object-actions.ts +1 -0
  42. package/src/builder/object-browser.ts +7 -1
  43. package/src/builder/plan-view.ts +25 -0
  44. package/src/builder/rest-modules.ts +629 -0
  45. package/src/builder/rest.ts +1 -0
  46. package/src/builder/routes.ts +4 -2
  47. package/src/builder/scheduling.ts +495 -0
  48. package/src/builder/sql-charts.ts +203 -0
  49. package/src/builder/sql-monitor.ts +397 -0
  50. package/src/builder/sql-tools.ts +53 -0
  51. package/src/builder/sql-utilities.ts +7 -39
  52. package/src/builder/sql.ts +20 -9
  53. package/src/builder/table-editor.ts +4 -1
  54. package/src/builder/top-sql.ts +24 -6
  55. package/src/builder/ui.ts +25 -17
  56. package/src/builder/worksheet.ts +35 -28
  57. package/src/builder/workspaces.ts +4 -2
  58. package/src/changelog.ts +109 -0
  59. package/src/cli/replace.ts +3 -1
  60. package/src/cron-schedule.ts +282 -0
  61. package/src/datamodel.ts +324 -0
  62. package/src/datapump.ts +546 -0
  63. package/src/dbroles.ts +215 -0
  64. package/src/dumpscript.ts +407 -0
  65. package/src/pgcron.ts +225 -0
  66. package/src/runtime/autorest.ts +336 -0
  67. package/src/runtime/rest-handlers.ts +75 -0
  68. package/src/runtime/rest.ts +120 -26
  69. package/src/session.ts +2 -0
@@ -1,17 +1,17 @@
1
1
  # 13. REST APIs
2
2
 
3
- pgkiln offers two ways to expose data securely over REST (mobile apps, integrations, scripts),
3
+ pgkiln offers three ways to expose data securely over REST (mobile apps, integrations, scripts),
4
4
  which can be combined:
5
5
 
6
- | | [REST modules](#rest-modules-in-the-builder) | [PostgREST](#how-it-works) |
7
- |---|---|---|
8
- | What | Endpoints you define in the builder: a method, a path and the SQL | A separate service that turns a schema of views and functions into an API |
9
- | Served by | pgkiln itself, under `/a/<alias>/rest/<module>/` | PostgREST, next to pgkiln |
10
- | Runs as | the application's database role (like its pages) | a dedicated API role |
11
- | Good for | a handful of tailored endpoints, field apps, integrations | broad data access with filtering, sorting and embedding |
6
+ | | [REST modules](#rest-modules-in-the-builder) | [AutoREST](#autorest-tables-and-views-from-the-sql-workshop) | [PostgREST](#how-it-works) |
7
+ |---|---|---|---|
8
+ | What | Endpoints you define in the builder: a method, a path and the SQL | A table or view published as it is: rows, one row, insert, update, delete | A separate service that turns a schema of views and functions into an API |
9
+ | Served by | pgkiln itself, under `/a/<alias>/rest/<module>/` | pgkiln itself, under `/a/<alias>/autorest/<object>/` | PostgREST, next to pgkiln |
10
+ | Runs as | the application's database role (like its pages) | the application's database role | a dedicated API role |
11
+ | Good for | a handful of tailored endpoints, field apps, integrations | quick CRUD access to a table, prototypes, simple integrations | broad data access with filtering, sorting and embedding |
12
12
 
13
- Both use the same tokens (App → REST API: tokens and OAuth clients), the same accounts and the
14
- same row level security, and both publish an OpenAPI description.
13
+ All three use the same tokens (App → REST API: tokens and OAuth clients), the same accounts and the
14
+ same row level security, and all publish an OpenAPI description.
15
15
 
16
16
  ## REST modules in the builder
17
17
 
@@ -31,11 +31,12 @@ answer 404.
31
31
  |---|---|
32
32
  | `method` | `GET`, `POST`, `PUT`, `PATCH` or `DELETE` |
33
33
  | `path` | Segments with `:parameters`, e.g. `orders/:id/lines` |
34
- | `type` | `collection`: a SELECT, returned page by page as `{items, offset, limit, has_more}` (`?limit=` up to 500, `?offset=`, `page_size` for the default); the page streams from a cursor as a chunked JSON array (an error before the first rows is still a 4xx/5xx, a failure after them ends the response short) · `item`: one row as an object, 404 when there is none · `sql`: statements; the first row of the last one is the response (201 for POST, 204 without a row; `status` overrides) |
34
+ | `type` | `collection`: a SELECT, returned page by page as `{items, offset, limit, has_more}` (`?limit=` up to 500, `?offset=`, `page_size` for the default); the page streams from a cursor as a chunked JSON array (an error before the first rows is still a 4xx/5xx, a failure after them ends the response short) · `item`: one row as an object, 404 when there is none · `sql`: statements; the first row of the last one is the response (201 for POST, 204 without a row; `status` overrides) · `plpgsql` (sprint 104): a PL/pgSQL block that answers with `meta.rest_reply(body, status)` ([below](#modules-templates-and-handlers-in-the-sql-workshop)) · `media`: a query whose row has `content_type`, `content` and optionally `filename`, sent as that content |
35
35
  | `source` | The SQL. Binds: path parameters, query parameters and the fields of a JSON (or form) body, upper case (`:ID`, `:CUSTOMER`); `:BODY` is the whole JSON body. `:APP_USER` (the caller), `:APP_ID` and `:APP_ALIAS` are set by the server: a parameter of that name (or `APP_SESSION`, `APP_PAGE_ID`, `REQUEST`, `APP_LANGUAGE`) is ignored, and can't be a path parameter |
36
36
  | `roles` | The caller needs one of these roles (as `meta.has_role()` sees them) |
37
37
  | `auth` | `token` (default) or `public` (no token; the SQL runs with `meta.app_user()` = `nobody`) |
38
38
  | `description` | For the OpenAPI description |
39
+ | `params` | (sprint 104) Documented parameters: `[{"name": "start_date", "in": "body", "type": "string", "required": true, "description": "…"}]` (`in`: `query` or `body`; types `string`, `integer`, `number`, `boolean`). They go into the OpenAPI document; a required one that is missing or empty answers 400 |
39
40
 
40
41
  **Calling them.** Send `Authorization: Bearer <token>` with a token from **App → REST API**: a token
41
42
  issued for an account, or one from `POST /oauth/token` for an OAuth client ([below](#tokens)). An
@@ -63,6 +64,128 @@ curl -X POST http://127.0.0.1:3100/a/hr/rest/v1/leave -H "Authorization: Bearer
63
64
  -H "Content-Type: application/json" -d '{"start_date": "2027-05-03", "end_date": "2027-05-04", "reason": "dentist"}'
64
65
  ```
65
66
 
67
+ ## Modules, templates and handlers in the SQL Workshop
68
+
69
+ **SQL Workshop → RESTful Services → Modules** (sprint 104; Oracle Database Actions / ORDS: REST › Modules) edits
70
+ the same REST modules the ORDS way, for every application of the workspace:
71
+
72
+ - a **module** is a base path, `/a/<alias>/rest/<name>/`, with a title, a description, *Enabled* and its
73
+ **privilege**: application roles (as `meta.has_role()` sees them) of which a caller needs one for *every*
74
+ handler of the module, public ones included;
75
+ - a **template** is a URI below it with `:parameters`, e.g. `emp/:id`, with a description for the OpenAPI document
76
+ (a template can exist before its first handler; renaming it moves its handlers);
77
+ - a **handler** answers one method of a template: its source type (*collection query*, *single row*, *SQL*,
78
+ *PL/pgSQL*, *media*), the source, who may call it (a token, or explicitly *public*), further roles, the page size
79
+ or status, and documented parameters, one per line: `start_date body string required First day`.
80
+
81
+ The tree on the left of each page shows module › templates › handlers. A Run only or locked application refuses
82
+ changes. The module's page checks the role the handlers run as and links the module's **OpenAPI** document.
83
+
84
+ **PL/pgSQL handlers** run their block as a `DO` statement as the application's database role. A `DO` block takes
85
+ no parameters, so pgkiln sets the request's binds as transaction-local settings (with query parameters: values
86
+ never become SQL text) and turns `:NAME` in the block into `meta.rest_param('NAME')` (text, NULL when absent):
87
+
88
+ ```sql
89
+ declare
90
+ v_id int;
91
+ begin
92
+ v_id := sales.create_order(:CUSTOMER, :TOTAL::numeric);
93
+ perform meta.rest_reply(jsonb_build_object('id', v_id), 201);
94
+ end
95
+ ```
96
+
97
+ `meta.rest_reply(body jsonb, status int)` sets the response (status 200–599, also 4xx for your own errors; the
98
+ transaction is still committed); without it the handler answers 204. A `raise exception` answers 400 with its
99
+ message and rolls back. Statements without `begin … end` are wrapped in one.
100
+
101
+ **Media handlers** run a query whose first row has `content_type`, `content` (bytea or text) and optionally
102
+ `filename`; the response is that content, sent with `X-Content-Type-Options: nosniff` and a sandboxing
103
+ Content-Security-Policy, so HTML or SVG from a table can't run script in the application's origin. No row: 404.
104
+
105
+ **Try it.** A handler's page sends a request to the real endpoint, with path parameters, a query string and a JSON
106
+ body, either without a token (to see the 401) or *as* an account of the application with a short-lived token (the
107
+ same as issuing one on App → REST API). A PUT, POST or DELETE changes data. The response's status and body are
108
+ shown in the page; it works without JavaScript.
109
+
110
+ **Example:** the HR example's module `hr` (`examples/hr/hr_62_rest_module.sql`): `GET emp/:id` (single row),
111
+ `GET emp/:id/photo` (media), `POST leave` (PL/pgSQL with body parameters) and `GET payroll/:deptno` (roles manager or
112
+ admin):
113
+
114
+ ```bash
115
+ curl http://127.0.0.1:3100/a/hr/rest/hr/emp/7839 -H "Authorization: Bearer $TOKEN"
116
+ curl -X POST http://127.0.0.1:3100/a/hr/rest/hr/leave -H "Authorization: Bearer $TOKEN" \
117
+ -H "Content-Type: application/json" -d '{"start_date": "2027-05-03", "end_date": "2027-05-04", "reason": "dentist"}'
118
+ # {"id": 12, "status": "requested"}
119
+ ```
120
+
121
+ Handlers never run as a superuser, a role that bypasses row level security or pgkiln's owner: such a request
122
+ answers 403 (set `RUNTIME_DATABASE_URL` and give the application its own database role).
123
+
124
+ ## AutoREST: tables and views from the SQL Workshop
125
+
126
+ **SQL Workshop → RESTful Services** (`/builder/sql/rest`; Database Actions: a table's *REST › Enable*, ORDS
127
+ AutoREST), or **REST › Enable / Disable** in a table's or view's ⋯ menu (SQL Worksheet navigator, Object Browser).
128
+ Choose:
129
+
130
+ | Setting | Meaning |
131
+ |---|---|
132
+ | Table or view | Any table, partitioned table, view, materialized view or foreign table of a schema the SQL Workshop may use (not `meta`) |
133
+ | Application | An application of the current workspace with a database role. It decides how requests run (below) and gives the URL |
134
+ | URL name | The segment in `/a/<alias>/autorest/<name>/`: lower case letters, digits, `_` and `-`; the object's name by default |
135
+ | Methods | `GET` (rows and one row), `POST` (insert), `PUT` (update), `DELETE`. `PUT` and `DELETE` need a primary key; a materialized view answers `GET` only |
136
+ | Who may call it | **A bearer token of the application** (the default), or **public**: anyone, without a token. Public is never implied: it is a choice on the form |
137
+ | Roles | Optional application roles; the caller needs one of them (`meta.has_role()`), else 403 |
138
+ | Page size | Rows per page without `?limit=` (1–500, default 25) |
139
+
140
+ The object's page shows the checks (the role is not the owner and doesn't bypass row level security; its grants per
141
+ method; row level security on or off; for a view whether it is `security_invoker`; the primary key; columns the role
142
+ may not read), the endpoints with their URLs, cURL and `fetch` examples, and the OpenAPI link. Its settings can be
143
+ changed, the object disabled (404) or removed (*Disable REST*). The table itself is never changed.
144
+
145
+ **Endpoints** (`/a/hr/autorest/dept/` for `hr.dept` published through `hr`):
146
+
147
+ | Request | Answer |
148
+ |---|---|
149
+ | `GET …/dept/` | `{"items": […], "offset": 0, "limit": 25, "has_more": true}`. `?limit=` (1–500) and `?offset=` page; `?<column>=<value>` keeps rows whose column equals the value (several are combined with AND); `?q=<text>` keeps rows where a text column contains the text (case-insensitive; `%` and `_` are literal); `?order=dname,deptno.desc` sorts (default: the primary key). An unknown parameter or column is a 400 |
150
+ | `GET …/dept/10` | The row as an object, or 404. A composite key is comma separated: `…/pair/1,x` (URL-encode a comma inside a value) |
151
+ | `POST …/dept/` | A JSON object of column values → 201 with the new row and a `Location` header. Columns left out get their defaults; an unknown column is a 400 |
152
+ | `PUT …/dept/10` | A JSON object of the columns to change → 200 with the row, or 404 (also when row level security hides it). Columns left out keep their values |
153
+ | `DELETE …/dept/10` | 204, or 404 |
154
+ | `GET /a/hr/autorest/openapi.json` | OpenAPI 3 of the application's published objects: without a token the public ones, with a valid token all of them; column types as JSON Schema |
155
+
156
+ A method that isn't enabled answers 405 with an `Allow` header. Errors are JSON `{"error": "…"}`, as for REST modules:
157
+ 400 for invalid input, a check or unique constraint, or a `raise exception`; 403 for a missing role or grant
158
+ (including column grants and a row level security `with check`).
159
+
160
+ **How a request runs.** As for [REST modules](#rest-modules-in-the-builder): the token is checked (signature, expiry,
161
+ the application, an active account with access or a client that isn't revoked) at every request; a browser session is
162
+ not accepted. The statement then runs in a transaction **as the application's database role**, with
163
+ `meta.app_user()` = the caller (`nobody` for a public object without a token), `meta.has_role()` reading the caller's
164
+ roles, and a 30-second statement timeout. So the role's grants, column grants and the table's row level security
165
+ apply exactly as on the application's pages. Columns the role may not select are left out of every answer and can't
166
+ be filtered or sorted on. AutoREST refuses, when the object is enabled and again at every request (after `SET ROLE`),
167
+ a role that owns the object or is a member of its owner (row level security doesn't apply to the owner), a superuser
168
+ and a role with `BYPASSRLS`. An application without a database role can't publish objects. Names come from the
169
+ catalog and are quoted; filter values, keys and bodies are query parameters, never SQL text.
170
+
171
+ The workspace preference *Enable RESTful services* turns AutoREST off with the REST modules (404), and the
172
+ application's availability applies too.
173
+
174
+ ```bash
175
+ TOKEN=… # App → REST API → Issue a token for king
176
+ curl "http://127.0.0.1:3100/a/hr/autorest/dept/?limit=2" -H "Authorization: Bearer $TOKEN"
177
+ # {"items":[{"deptno":10,"dname":"ACCOUNTING",…},{"deptno":20,…}],"offset":0,"limit":2,"has_more":true}
178
+ curl "http://127.0.0.1:3100/a/hr/autorest/dept/?limit=2" # 401: no token
179
+ ```
180
+
181
+ **Views.** A view without `security_invoker = true` runs with its owner's rights, so the row level security of the
182
+ tables beneath it does not apply to the caller; the page warns about it. Views have no primary key: they publish the
183
+ collection (`GET`, and `POST` when PostgreSQL can insert into the view).
184
+
185
+ **Export.** The published objects are installation data, like OAuth clients: they name objects of this database and
186
+ are not part of an application export; an in-place import (`pgkiln import --replace`) keeps them. Tailored endpoints
187
+ that travel with the application are [REST modules](#rest-modules-in-the-builder).
188
+
66
189
  ## PostgREST
67
190
 
68
191
  [PostgREST](https://postgrest.org) runs as a separate service **next to** pgkiln and turns
@@ -19,7 +19,7 @@ setup`) and on practically every managed service. Install with `create extension
19
19
  | **unaccent** | Accent-insensitive search (`café` finds `cafe`), essential in multilingual apps | Text search options |
20
20
  | **citext** | Case-insensitive text columns (e-mail addresses, codes) without `lower()` everywhere | NLS_COMP=LINGUISTIC |
21
21
  | **btree_gist** | Exclusion constraints on ranges, e.g. **no overlapping bookings/leave** enforced by the database, race-free | Custom PL/SQL checks |
22
- | **pg_stat_statements** | Which SQL is slow or called most, per role, so per app (`db_role`) | Monitor Activity → Top SQL |
22
+ | **pg_stat_statements** | Which SQL is slow or called most, per role, so per app (`db_role`) | Monitor Activity → Top SQL; SQL Workshop → SQL Monitor (Top SQL, a statement's totals) |
23
23
  | **tablefunc** | `crosstab()` for pivot tables | Interactive report *Pivot* |
24
24
  | **ltree** | Hierarchies (org charts, categories, menus) with fast subtree queries | Tree region, `CONNECT BY` |
25
25
  | **postgres_fdw**, **file_fdw** | Query another PostgreSQL database, or a CSV file, as if it were a table | Remote servers / database links |
@@ -0,0 +1,39 @@
1
+ -- =====================================================================
2
+ -- HR example, part 62: a REST module built in SQL Workshop › RESTful
3
+ -- Services (sprint 104; docs/guide/13-rest-api.md → Modules, templates
4
+ -- and handlers). Module "hr" at /a/hr/rest/hr/, with a token from
5
+ -- App › REST API:
6
+ --
7
+ -- emp/ GET collection query: the active employees
8
+ -- emp/:id GET single row: one employee
9
+ -- emp/:id/photo GET media: the employee's photo
10
+ -- leave POST PL/pgSQL: request leave with
11
+ -- {"start_date": "…", "end_date": "…", "reason": "…"}
12
+ -- payroll/:deptno GET collection query; managers and administrators only
13
+ -- =====================================================================
14
+ insert into meta.rest_module (app_id, name, title, description, templates, handlers)
15
+ select id, 'hr', 'HR (RESTful Services)', 'Employees and leave requests: a module of templates and handlers.',
16
+ $t$[
17
+ {"uri": "emp", "description": "The employees"},
18
+ {"uri": "emp/:id", "description": "One employee by number"},
19
+ {"uri": "emp/:id/photo", "description": "An employee's photo"},
20
+ {"uri": "leave", "description": "Leave requests of the caller"},
21
+ {"uri": "payroll/:deptno", "description": "Payroll of a department"}
22
+ ]$t$::jsonb,
23
+ $h$[
24
+ {"method": "GET", "path": "emp", "type": "collection", "description": "The active employees",
25
+ "source": "select empno, initcap(ename) as name, initcap(job) as job, deptno from hr.emp where active order by empno"},
26
+ {"method": "GET", "path": "emp/:id", "type": "item", "description": "One employee",
27
+ "source": "select empno, initcap(ename) as name, initcap(job) as job, deptno, hiredate, mgr from hr.emp where empno = :ID::int"},
28
+ {"method": "GET", "path": "emp/:id/photo", "type": "media", "description": "The employee's photo",
29
+ "source": "select photo_mime as content_type, photo as content, photo_name as filename from hr.emp where empno = :ID::int and photo is not null"},
30
+ {"method": "POST", "path": "leave", "type": "plpgsql", "description": "Request leave (as the caller)",
31
+ "params": [{"name": "start_date", "in": "body", "type": "string", "required": true, "description": "First day (YYYY-MM-DD)"},
32
+ {"name": "end_date", "in": "body", "type": "string", "required": true, "description": "Last day (YYYY-MM-DD)"},
33
+ {"name": "reason", "in": "body", "type": "string"}],
34
+ "source": "declare\n v_id int;\nbegin\n v_id := hr.request_leave(:START_DATE::date, :END_DATE::date, :REASON);\n perform meta.rest_reply(jsonb_build_object('id', v_id, 'status', 'requested'), 201);\nend"},
35
+ {"method": "GET", "path": "payroll/:deptno", "type": "collection", "roles": ["manager", "admin"], "description": "Salaries of a department",
36
+ "source": "select empno, initcap(ename) as name, sal from hr.emp where deptno = :DEPTNO::int and active order by empno"}
37
+ ]$h$::jsonb
38
+ from meta.app where alias = 'hr'
39
+ on conflict (app_id, name) do nothing;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
package/public/app.css CHANGED
@@ -416,7 +416,7 @@ dialog.region-inline_drawer:target { inset: 0 0 0 auto; }
416
416
  .req { color: var(--danger); margin-left: 0.2em; }
417
417
  input:not([type=checkbox]):not([type=radio]), select, textarea {
418
418
  font: inherit; color: var(--text); background: var(--surface);
419
- border: 1px solid var(--border); border-radius: calc(var(--radius) * 0.75); padding: 0.45rem 0.6rem; width: 100%; min-width: 0;
419
+ border: 1px solid var(--field-border, var(--border)); border-radius: calc(var(--radius) * 0.75); padding: 0.45rem 0.6rem; width: 100%; min-width: 0;
420
420
  }
421
421
  input:disabled, select:disabled, textarea:disabled { opacity: 0.6; }
422
422
  textarea.code { font-family: var(--mono); font-size: 0.84rem; line-height: 1.45; tab-size: 2; }
@@ -30,4 +30,6 @@
30
30
  <symbol id="b-save" viewBox="0 0 24 24"><path d="M5 4h11l3 3v13H5z"/><path d="M8 4v5h7V4M8 20v-6h8v6"/></symbol>
31
31
  <symbol id="b-dots" viewBox="0 0 24 24"><path d="M5 12h.01M12 12h.01M19 12h.01" stroke-width="3.2"/></symbol>
32
32
  <symbol id="b-pages" viewBox="0 0 24 24"><path d="M8 3h8l4 4v11H8z"/><path d="M16 3v4h4"/><path d="M4 7v14h11"/></symbol>
33
+ <!-- (sprint 105) the pgkiln brand mark: an ember flame with a hollow core; filled, unlike the stroke icons -->
34
+ <symbol id="b-kiln" viewBox="0 0 24 24"><path fill="currentColor" stroke="none" fill-rule="evenodd" d="M12.3 1.8c.6 3.1 2.5 4.7 4.2 6.6 1.8 1.9 3.1 4 3.1 6.7a7.6 7.6 0 0 1-15.2 0c0-2.6 1.1-4.6 2.8-6.1.2 1.6 1 2.8 2.2 3.4C9.2 8.6 10.5 4.7 12.3 1.8zM12 20.4a2.9 2.9 0 0 0 2.9-2.9c0-1.4-.5-2.9-1.4-4-.8.8-1.3 1.8-1.4 3-.7-.3-1.1-.9-1.3-1.6-.9.9-1.8 1.9-1.8 3.3a3 3 0 0 0 3 2.2z"/></symbol>
33
35
  </svg>